From f9f36d70217ee08a3008a507e89376cb4580e867 Mon Sep 17 00:00:00 2001 From: Streaky Date: Wed, 1 Jul 2026 01:01:56 +0100 Subject: [PATCH 01/26] basic files checkin and docs --- .gitignore | 7 + AGENTS.md | 54 ++++ Makefile | 93 +++++++ README.md | 145 ++++++++++- acme.api.code-workspace | 8 + dev/check_per_file_coverage.py | 30 +++ docs/outline.md | 436 +++++++++++++++++++++++++++++++++ pyproject.toml | 88 +++++++ requirements-dev.txt | 80 ++++++ requirements.txt | 8 + 10 files changed, 948 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 Makefile create mode 100644 acme.api.code-workspace create mode 100644 dev/check_per_file_coverage.py create mode 100644 docs/outline.md create mode 100644 pyproject.toml create mode 100644 requirements-dev.txt create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..03c696f --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ + +/*.egg-info +/.venv +/.pycache +/.mypy_cache + + diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a602878 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,54 @@ +# Agent Notes + +## Project Overview + +**acme.api** is a lightweight, self-hosted REST service for managing ACME certificates via DNS-01 validation. It delegates the ACME protocol to `acme.sh` and exposes a modern HTTP API for certificate lifecycle management, automatic renewals, and webhook notifications. See `docs/outline.md` for full design. + +## Architecture (v1) + +- **Backend**: `acme.sh` (only supported ACME implementation at this time). +- **Validation**: DNS-01 only; DNS providers are admin-defined aliases referenced by name in the API. +- **Deployment**: Single Docker container; certificates written atomically to a shared filesystem (`/certificates//`). +- **Config**: `config.yaml` (YAML-based, parsed via PyYAML). + +## Directory Layout + +``` +acme_api/ # main package (currently empty — awaiting implementation) +tests/ # pytest test suite; fixtures in tests/fixtures/ +dev/ # helper scripts (e.g. per-file coverage checker) +docs/ # project outline and design docs +Makefile # all build/lint/test commands +``` + +## Development Standards + +- Follow PEP 8 and modern Python practices. +- Use type hints for all functions and methods. +- Write maintainable, modular, testable, and well-documented code. +- Add docstrings for all public functions and classes. +- Configuration is stored in `config.yaml`. + +## Project Commands + +| Command | Description | +|---|---| +| `make dev` | Set up venv + install all dependencies (required before other targets). | +| `make deps-update` | Regenerate pinned `requirements.txt` / `requirements-dev.txt` from pyproject.toml via pip-tools. | +| `make test [TEST=...]` | Run pytest with coverage. Scope to a file/path with `TEST=path/to/test.py`. Runs per-file coverage gate (default 80%). | +| `make typecheck` | Run mypy (strict mode, Python 3.14). | +| `make lint` | Run flake8 + pylint (fail-under 10.0). | +| `make isort` | Check import ordering (--check-only --diff). | +| `make format` | Apply isort formatting in-place. | +| `make combined-check` | Run typecheck, lint, flake8, isort, check-max-lines, and test in one shot. | +| `make build` | Build Docker image via docker compose. | +| `make start` | Start the service (depends on build). | +| `make stop` | Stop the service. | +| `make logs` | Follow container logs. | + +## Fixtures And Documentation + +- Tests live in `tests/`; fixtures in `tests/fixtures/`. +- Use `make test` (not raw pytest) to ensure coverage gates are enforced. +- Update `README.md` with project description, installation instructions, usage, and other relevant information. +- Update this file (`AGENTS.md`) with useful information that may help future agents. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..badecb1 --- /dev/null +++ b/Makefile @@ -0,0 +1,93 @@ +.PHONY: venv deps dev start test typecheck lint flake8 format isort isort-fix combined-check install-act simulate-ci deps-update +.ONESHELL: + +export ROOT_PATH=$(abspath $(dir $(lastword $(MAKEFILE_LIST)))) +export PYTHONPATH=$(ROOT_PATH) +COV_FILE_MIN ?= 80 +PY_PATHS ?= acme_api tests +MYPY_PATHS ?= acme_api tests +MAX_FILE_LINES ?= 500 +MAX_FILE_LINES_PATHS ?= acme_api tests + +ifneq (,$(wildcard $(ROOT_PATH)/.env)) +include $(ROOT_PATH)/.env +export +endif + +export PYTHONPYCACHEPREFIX=./.pycache + +venv: + if [ ! -d ".venv" ]; then \ + python3 -m venv .venv; \ + fi + +pip-cache-dir: + mkdir -p .venv/pip-cache + +dev: venv pip-cache-dir + echo "Installing dependencies..." + .venv/bin/python3 -m pip install -q --cache-dir .venv/pip-cache --upgrade pip + .venv/bin/pip install -q --cache-dir .venv/pip-cache -r requirements-dev.txt + .venv/bin/pip install -q --cache-dir .venv/pip-cache -e . + +deps-update: venv pip-cache-dir + .venv/bin/python3 -m pip install --cache-dir .venv/pip-cache --upgrade pip setuptools wheel pip-tools + .venv/bin/pip-compile pyproject.toml --output-file requirements.txt --strip-extras --pip-args "--cache-dir=.venv/pip-cache" + .venv/bin/pip-compile pyproject.toml --extra dev -c requirements.txt --output-file requirements-dev.txt --no-strip-extras --pip-args "--cache-dir=.venv/pip-cache" + +deps: venv + .venv/bin/pip install -r requirements.txt + +start: build + docker compose up -d + +build: + docker compose build --pull + +stop: + docker compose down + +logs: + docker compose logs -f --tail=150 + +test: dev + set -e + .venv/bin/pytest -vvv --tb=short --color=yes ${PYTEST_COV} --cov-report=xml:coverage-data/coverage.xml --cov-report=html:coverage-data/htmlcov --cov-report=term --cov-report=json:coverage-data/coverage.json ${TEST} +ifeq ($(origin TEST), command line) + @echo "Skipping per-file coverage gate for scoped TEST=${TEST}" +else + .venv/bin/python3 dev/check_per_file_coverage.py --minimum "${COV_FILE_MIN}" --coverage-json coverage-data/coverage.json +endif + +flake8: dev + .venv/bin/flake8 ${PY_PATHS} --max-line-length=120 --extend-ignore=E203 --count --show-source --statistics + +lint: flake8 + .venv/bin/pylint ${PY_PATHS} + +format: dev + .venv/bin/isort ${PY_PATHS} + +isort: dev + .venv/bin/isort ${PY_PATHS} --check-only --diff + +check-max-lines: dev + .venv/bin/python3 scripts/check_max_lines.py --max-lines "${MAX_FILE_LINES}" ${MAX_FILE_LINES_PATHS} + +isort-fix: dev + .venv/bin/isort ${PY_PATHS} --interactive + +typecheck: dev + .venv/bin/mypy ${MYPY_PATHS} + +combined-check: typecheck lint flake8 isort check-max-lines test + +install-act: + [ -x ./act ] && echo "act is already installed" && exit 0 + wget https://github.com/nektos/act/releases/download/v$(ACT_VERSION)/act_$(ACT_PLATFORM).tar.gz + tar -xzf act_$(ACT_PLATFORM).tar.gz act + rm act_$(ACT_PLATFORM).tar.gz + chmod +x act + +simulate-ci: install-act + ./act -P ubuntu-latest=$(ACT_IMAGE) diff --git a/README.md b/README.md index a4e1dc6..d1d6c7c 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,145 @@ # acme.api -acme.api is a lightweight, self-hosted REST service for managing ACME certificates; in the first instance focusing on acme.sh as a backend. + +A lightweight, self-hosted REST service for managing ACME certificates — backed by `acme.sh`. + +## Philosophy + +**acme.api is not another ACME client.** It's a certificate management service with a clean REST interface that delegates all protocol work to mature tooling. By separating the infrastructure API from the ACME implementation, applications integrate once and remain independent of the underlying ACME client. + +## Quick Start + +```sh +docker run -d \ + --name acme-api \ + -v /path/to/certs:/certificates \ + -v /path/acme.sh-data:/acmesh \ + -p 8080:8080 \ + ghcr.io/acme.api/acme.api:latest +``` + +Three persistent volumes — `/data`, `/certificates`, `/acmesh` — each with a distinct purpose. See the [deployment section](#deployment) for details. + +Then interact via the REST API: + +```sh +# Check readiness +curl http://localhost:8080/ready + +# List certificates +curl http://localhost:8080/v1/certificates +``` + +## Features + +| Area | What's included (v1) | +|---|---| +| **Certificate lifecycle** | Issue, renew, revoke via REST API | +| **Validation** | DNS-01 with persistent provider mode — credentials configured once by admin | +| **ACME backend** | `acme.sh` — extensible to lego or native later without API changes | +| **Accounts** | Manage multiple CA accounts (Let's Encrypt prod/staging, ZeroSSL) | +| **Renewals** | Automatic before expiry with tracked state machine | +| **Webhooks** | Events on issue/renew/fail/expiry/revocation | +| **Metrics** | Prometheus-compatible counters for certs, renewals, webhooks | +| **Logging** | Structured JSON to stdout and file | +| **Auth (v1)** | API key authentication with RBAC roles: Admin, Operator, Read Only | + +### Out of scope (v1) + +HTTP-01, TLS-ALPN-01, per-request DNS credentials, web UI, HA/clustering. + +## REST API + +Full OpenAPI spec is generated from the codebase. Key endpoints: + +| Method | Path | Description | +|---|---|---| +| `POST` | `/v1/certificates` | Register and issue a certificate | +| `GET` | `/v1/certificates` | List all certificates | +| `GET` | `/v1/certificates/{id}` | Get certificate details | +| `DELETE` | `/v1/certificates/{id}` | Revoke and remove | +| `POST` | `/v1/certificates/{id}/renew` | Force renewal | +| `GET` | `/v1/accounts` | List ACME accounts | +| `GET` | `/v1/providers` | List DNS providers | +| `GET` | `/v1/events` | Event history | +| `GET` | `/health` | Liveness probe | +| `GET` | `/ready` | Readiness probe | + +### Example: issue a certificate + +```json +POST /v1/certificates + +{ + "name": "mail.example.com", + "domains": ["*.example.com", "example.com"], + "dns_provider": "production", + "account": "letsencrypt-production" +} +``` + +### Certificate key algorithms + +- RSA (default) +- ECDSA P-256 +- ECDSA P-384 + +## Architecture + +``` + REST API + │ + ┌───────────────┴───────────────┐ + │ │ + Certificate Manager Renewal Scheduler + │ │ + └───────────────┬───────────────┘ + │ + ACME Backend + │ + acme.sh + │ + Let's Encrypt / ZeroSSL / Buypass +``` + +The public API is backend-independent. The ACME implementation (currently `acme.sh`) is an internal detail. + +## Deployment + +### Docker volumes + +| Mount | Purpose | Survives upgrades? | +|---|---|---| +| `/data` | SQLite database — certs, accounts, providers, webhooks, audit log | Yes | +| `/certificates` | Issued certificate files — atomic deployment via rename | Yes | +| `/acmesh` | `acme.sh` home directory and account state | Yes | + +### Shared filesystem model + +Certificates are written atomically: temporary files → flush → rename. Consumers never see partially-written certificates. Layout per domain: + +``` +/certificates/mail.example.com/ + cert.pem + chain.pem + fullchain.pem + privkey.pem + metadata.json +``` + +## Design Principles + +- **API-first** — everything through REST, no CLI dependency +- **Backend-independent** — swap ACME client without breaking integrations +- **Simple deployment** — one container, three volumes, YAML config +- **Opinionated defaults** — works out of the box with sensible settings +- **Minimal configuration** — DNS providers configured once by admin +- **No Kubernetes requirement** — runs anywhere Docker does +- **No external database** — SQLite is sufficient for v1 + +## Project status + +Prototype. API surface and architecture are defined; implementation underway. See `docs/outline.md` for the full specification. + +## License + +See [LICENSE](LICENSE). diff --git a/acme.api.code-workspace b/acme.api.code-workspace new file mode 100644 index 0000000..517e0b2 --- /dev/null +++ b/acme.api.code-workspace @@ -0,0 +1,8 @@ +{ + "folders": [ + { + "path": "." + } + ], + "settings": {} +} \ No newline at end of file diff --git a/dev/check_per_file_coverage.py b/dev/check_per_file_coverage.py new file mode 100644 index 0000000..737d9f0 --- /dev/null +++ b/dev/check_per_file_coverage.py @@ -0,0 +1,30 @@ +"""Check per-file statement coverage from coverage.py JSON output.""" + +import argparse +import json +from pathlib import Path + + +def main() -> None: + """Validate that every measured Python file meets the configured coverage floor.""" + + parser = argparse.ArgumentParser() + parser.add_argument("--minimum", type=float, required=True) + parser.add_argument("--coverage-json", type=Path, required=True) + args = parser.parse_args() + + data = json.loads(args.coverage_json.read_text(encoding="utf-8")) + failures: list[str] = [] + for file_name, file_data in data["files"].items(): + summary = file_data["summary"] + if summary["num_statements"] == 0: + continue + percent = float(summary["percent_covered"]) + if percent < args.minimum: + failures.append(f"{file_name}: {percent:.2f}% < {args.minimum:.2f}%") + if failures: + raise SystemExit("Per-file coverage check failed:\n" + "\n".join(failures)) + + +if __name__ == "__main__": + main() diff --git a/docs/outline.md b/docs/outline.md new file mode 100644 index 0000000..7026838 --- /dev/null +++ b/docs/outline.md @@ -0,0 +1,436 @@ +# acme.api + +## Overview + +**acme.api** is a lightweight, self-hosted REST service for managing ACME certificates. + +It provides a modern HTTP API, automatic renewals, webhook notifications and shared filesystem deployment, while delegating all ACME protocol implementation to a mature backend (initially `acme.sh`). + +The project intentionally focuses on infrastructure integration rather than implementing the ACME protocol itself. + +--- + +# Goals + +* Provide a simple REST API for certificate lifecycle management. +* Support fully automated certificate issuance and renewal. +* Use proven ACME software rather than reimplementing the protocol. +* Be easy to deploy as a single Docker container. +* Be suitable for homelabs, hosting providers and internal infrastructure. +* Be completely open source. + +--- + +# Non-Goals (v1) + +The following are explicitly out of scope for the initial release: + +* HTTP-01 validation +* TLS-ALPN-01 validation +* Per-request DNS credentials +* Web user interface +* High availability / clustering +* Certificate authority implementation +* Reimplementation of the ACME protocol + +--- + +# Architecture + +``` + REST API + │ + ┌───────────────┴───────────────┐ + │ │ + Certificate Manager Renewal Scheduler + │ │ + └───────────────┬───────────────┘ + │ + ACME Backend + │ + acme.sh + │ + Let's Encrypt / ZeroSSL / Buypass +``` + +The ACME backend is considered an implementation detail. + +The public API must remain independent from the backend implementation. + +--- + +# Backend + +Version 1 uses: + +* acme.sh + +Future versions may support: + +* lego +* native implementation +* other ACME libraries + +without changing the public API. + +--- + +# Validation + +## Supported + +* DNS-01 + +## Required + +* DNS Persist Mode + +DNS providers are configured once by an administrator. + +API clients reference configured providers by name. + +Clients never transmit DNS credentials. + +Example: + +```json +{ + "domains": [ + "*.example.com", + "example.com" + ], + "dns_provider": "production" +} +``` + +--- + +# ACME Accounts + +The service manages one or more ACME accounts. + +Example: + +* letsencrypt-production +* letsencrypt-staging +* zerossl-production + +Certificates reference an account by name. + +--- + +# DNS Providers + +DNS providers are administrator-defined aliases. + +Example: + +``` +production +testing +internal +``` + +Each alias maps to the underlying acme.sh provider configuration. + +Clients are unaware of the underlying provider implementation. + +--- + +# REST API + +## Certificates + +``` +POST /v1/certificates + +GET /v1/certificates + +GET /v1/certificates/{id} + +DELETE /v1/certificates/{id} + +POST /v1/certificates/{id}/renew +``` + +--- + +## Accounts + +``` +GET /v1/accounts +``` + +--- + +## Providers + +``` +GET /v1/providers +``` + +--- + +## Events + +``` +GET /v1/events +``` + +--- + +## Health + +``` +GET /health + +GET /ready +``` + +--- + +# Certificate Model + +A certificate contains: + +* unique identifier +* friendly name +* domains +* ACME account +* DNS provider +* key algorithm +* expiry date +* renewal policy +* current status +* metadata + +Supported key algorithms: + +* RSA +* ECDSA P-256 +* ECDSA P-384 + +--- + +# Renewal + +The service automatically renews certificates before expiry. + +Renewal state is tracked internally. + +Possible states include: + +* Pending +* Issuing +* Valid +* Renewing +* Failed +* Revoked + +--- + +# Shared Filesystem Deployment + +Certificates are deployed into a shared filesystem. + +Example layout: + +``` +/certificates/ + + mail.example.com/ + + cert.pem + + chain.pem + + fullchain.pem + + privkey.pem + + metadata.json +``` + +Deployment should be atomic. + +Recommended process: + +1. Write temporary files. +2. Flush to disk. +3. Atomic rename. +4. Emit webhook event. + +Consumers never observe partially-written certificates. + +--- + +# Webhooks + +Supported events include: + +``` +certificate.created + +certificate.issued + +certificate.renewed + +certificate.failed + +certificate.expiring + +certificate.revoked +``` + +Webhook payload example: + +```json +{ + "event": "certificate.renewed", + "certificate": "mail.example.com", + "expires": "2027-05-20T14:00:00Z", + "domains": [ + "mail.example.com" + ] +} +``` + +--- + +# Security + +Authentication should support: + +* API Keys (v1) + +Future versions may add: + +* OAuth2 +* OpenID Connect +* Mutual TLS + +Authorization should be role-based. + +Suggested roles: + +* Administrator +* Operator +* Read Only + +--- + +# Storage + +Version 1 should use SQLite. + +Persistent data includes: + +* certificates +* accounts +* providers +* renewal schedule +* webhook configuration +* audit log + +Certificate material remains on the filesystem. + +--- + +# OpenAPI + +The REST API should be fully described using OpenAPI. + +Generated documentation should be published automatically. + +--- + +# Container + +Official Docker image. + +Recommended persistent volumes: + +``` +/data + +/certificates + +/acmesh +``` + +The acme.sh home directory is mounted separately so its state survives upgrades. + +--- + +# Logging + +Structured JSON logging. + +Support: + +* stdout +* file output + +Log levels: + +* Error +* Warning +* Info +* Debug + +--- + +# Metrics + +Expose Prometheus metrics. + +Example metrics: + +* certificates_total +* certificates_expiring +* renewals_total +* renewals_failed +* webhook_deliveries_total +* webhook_failures_total + +--- + +# Design Principles + +* API-first +* Backend-independent +* Simple deployment +* Opinionated defaults +* Minimal configuration +* No Kubernetes requirement +* No external database requirement +* No protocol reimplementation + +--- + +# Future Roadmap + +Potential future features include: + +* Additional ACME backends +* HTTP-01 support +* TLS-ALPN-01 support +* ACME External Account Binding (EAB) +* Remote deployment targets (SSH, Kubernetes Secrets, S3, etc.) +* Certificate revocation +* Certificate inventory search +* Multi-node deployments +* Web administration interface +* Fine-grained permissions +* Event streaming +* CLI generated from the OpenAPI specification + +--- + +# Philosophy + +acme.api is not another ACME client. + +It is a certificate management service with a clean REST interface that leverages mature, battle-tested ACME tooling underneath. + +By separating the infrastructure API from the ACME implementation, applications integrate once with acme.api while remaining independent of the underlying ACME client. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..bb9bb43 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,88 @@ +[project] +name = "acme.api" +version = "0.1.0" +description = "Lightweight, self-hosted REST service for managing ACME certificates via DNS-01 validation." +readme = "README.md" +license = { text = "MIT" } +authors = [{ name = "Streaky" }] +requires-python = ">=3.14" +keywords = [ + "acme", + "certificate", + "dns-01", + "letsencrypt", + "rest-api", +] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: System Administrators", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3.14", + "Topic :: Internet :: WWW/HTTP", + "Topic :: Security :: Cryptography", +] +dependencies = [ + "PyYAML", +] + +[project.urls] +"Homepage" = "https://github.com/streaky/acme.api" +"Source" = "https://github.com/streaky/acme.api" +"Bug Tracker" = "https://github.com/streaky/acme.api/issues" + +[project.optional-dependencies] +dev = [ + "black", + "flake8", + "isort", + "mypy", + "pytest", + "pytest-cov", + "pylint", + "types-PyYAML", +] + +[project.scripts] +# TODO: add entry point once the main module is implemented. + +[tool.setuptools] +package-dir = {"" = "."} + +[tool.setuptools.packages.find] +where = ["."] +include = ["acme_api*"] + +[tool.pylint.format] +max-line-length = 120 + +[tool.pylint.MASTER] +fail-under = 10.00 +load-plugins = [] + +[tool.pylint."MESSAGES CONTROL"] +disable = [ +] + +[tool.flake8] +max-line-length = 120 +extend-ignore = ["E203"] +count = true +statistics = true + +[tool.isort] +profile = "black" +src_paths = ["acme_api", "tests"] +known_first_party = ["acme_api"] + +[tool.pytest.ini_options] +pythonpath = ["."] +filterwarnings = [] +addopts = [ + "-vvv", + "--tb=short", + "--cov-report=term", +] + +[tool.mypy] +python_version = "3.14" +strict = true diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..6725ac6 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,80 @@ +# +# This file is autogenerated by pip-compile with Python 3.14 +# by the following command: +# +# pip-compile --constraint=requirements.txt --extra=dev --no-strip-extras --output-file=requirements-dev.txt --pip-args='--cache-dir=.venv/pip-cache' pyproject.toml +# +ast-serialize==0.6.0 + # via mypy +astroid==4.0.4 + # via pylint +black==26.5.1 + # via acme.api (pyproject.toml) +click==8.4.2 + # via black +coverage[toml]==7.14.3 + # via pytest-cov +dill==0.4.1 + # via pylint +flake8==7.3.0 + # via acme.api (pyproject.toml) +iniconfig==2.3.0 + # via pytest +isort==8.0.1 + # via + # acme.api (pyproject.toml) + # pylint +librt==0.12.0 + # via mypy +mccabe==0.7.0 + # via + # flake8 + # pylint +mypy==2.1.0 + # via acme.api (pyproject.toml) +mypy-extensions==1.1.0 + # via + # black + # mypy +packaging==26.2 + # via + # black + # pytest +pathspec==1.1.1 + # via + # black + # mypy +platformdirs==4.10.0 + # via + # black + # pylint +pluggy==1.6.0 + # via + # pytest + # pytest-cov +pycodestyle==2.14.0 + # via flake8 +pyflakes==3.4.0 + # via flake8 +pygments==2.20.0 + # via pytest +pylint==4.0.6 + # via acme.api (pyproject.toml) +pytest==9.1.1 + # via + # acme.api (pyproject.toml) + # pytest-cov +pytest-cov==7.1.0 + # via acme.api (pyproject.toml) +pytokens==0.4.1 + # via black +pyyaml==6.0.3 + # via + # -c requirements.txt + # acme.api (pyproject.toml) +tomlkit==0.15.0 + # via pylint +types-pyyaml==6.0.12.20260518 + # via acme.api (pyproject.toml) +typing-extensions==4.15.0 + # via mypy diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f1218da --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +# +# This file is autogenerated by pip-compile with Python 3.14 +# by the following command: +# +# pip-compile --output-file=requirements.txt --pip-args='--cache-dir=.venv/pip-cache' --strip-extras pyproject.toml +# +pyyaml==6.0.3 + # via acme.api (pyproject.toml) From 4f431656bfe92aaf1783df6ec34ba9507c6324b5 Mon Sep 17 00:00:00 2001 From: Streaky Date: Wed, 1 Jul 2026 02:36:52 +0100 Subject: [PATCH 02/26] implementation plan --- .gitignore | 10 +- docs/plan.md | 318 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 322 insertions(+), 6 deletions(-) create mode 100644 docs/plan.md diff --git a/.gitignore b/.gitignore index 03c696f..d50c4ec 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,5 @@ -/*.egg-info -/.venv -/.pycache -/.mypy_cache - - +*.egg-info/ +.venv/ +.pycache/ +.mypy_cache/ diff --git a/docs/plan.md b/docs/plan.md new file mode 100644 index 0000000..1df8aff --- /dev/null +++ b/docs/plan.md @@ -0,0 +1,318 @@ +# Implementation Plan — acme.api v1 + +> Derived from `docs/outline.md`. Targets Python 3.14, strict mypy, 80% per-file coverage gate. +> Greenfield codebase: `acme_api/` and `tests/` are currently empty. Infrastructure (Makefile, pyproject.toml) exists. + +--- + +## Phase 0 — Dependencies & Project Wiring + +**Goal:** Resolve the runtime dependency list and wire a working entry point so `make dev` + `make test` passes on an empty package. + +- Update `pyproject.toml` `[project.dependencies]`: + - FastAPI, Uvicorn (ASGI server) + - SQLAlchemy 2.x (async SQLite) + - APScheduler (renewal jobs) + - Pydantic Settings (`pydantic-settings`) — config loading beyond flat YAML + - HTTPX (webhook delivery; acme.sh subprocess parsing is local but webhooks are HTTP) + - Prometheus Client (`prometheus-client`) + - Passlib + bcrypt (API key hashing) + - `aiofiles` (async filesystem writes for atomic deployment) +- Update `[project.scripts]` entry point pointing to the main CLI. +- Regenerate `requirements.txt` / `requirements-dev.txt` via `make deps-update`. +- Bootstrap package skeleton: + - `acme_api/__init__.py`, `main.py` (app factory + uvicorn entry), `config.py` (YAML → Pydantic config model) + - `tests/conftest.py` (shared pytest fixtures, async support via anyio) +- Create placeholder `GET /health` returning `{"status": "ok"}` to verify the pipeline. + +**Acceptance:** `make combined-check` passes; Docker build succeeds with a health endpoint responding 200 OK. + +--- + +## Phase 1 — Configuration & Logging + +**Goal:** Runtime configuration from `config.yaml`, structured JSON logging, and app startup wiring. + +- `acme_api/config.py`: + - Pydantic model representing the full config schema (ACME paths, SQLite DSN, cert deployment dir, DNS providers, ACME accounts, log level). + - Config loader reads `config.yaml` from a configurable path (`ACME_API_CONFIG` env var). + - Validation on startup: reject missing required fields with clear errors. +- Structured JSON logging setup (`acme_api/logging.py`): + - JSON-formatted records to stdout and optionally to file. + - Configurable log level via config. + - Request ID context propagation (middleware injects per-request correlation ID). +- Example `config.yaml` shipped in the repo root as a reference (`config.example.yaml`). + +**Acceptance:** App starts with valid/invalid configs; structured logs emitted for lifecycle events; config validation errors surfaced at startup. + +--- + +## Phase 2 — Database Layer & Data Models + +**Goal:** SQLite-backed ORM models with migrations for all persistent entities defined in the outline (certificates, accounts, providers, renewal schedule, webhook configuration, audit log). + +- `acme_api/db.py`: async SQLAlchemy engine setup, session factory, connection pooling. +- `acme_api/models/` package: + - **Certificate**: id (UUID), name, domains (JSON array), acme_account_ref, dns_provider_ref, key_algorithm, expiry_date, status (Pending | Issuing | Valid | Renewing | Failed | Revoked), created_at, updated_at. + - **ACMEAccount**: name, server_url (LE prod/staging, ZeroSSL, Buypass), account_key_path (filesystem path managed by acme.sh). + - **DNSProvider**: name, provider_name (acme.sh provider alias e.g. `cloudflare`), env_vars_file_path (path to file with DNS credentials — avoids passing secrets in memory from the API layer). + - **WebhookConfig**: id, url, events (JSON array of event types it subscribes to), secret (for HMAC signing). + - **Event** (audit log): id, timestamp, event_type, certificate_ref (nullable), details (JSON), status. +- Alembic integration for migrations: initial migration covering all tables. +- Pydantic schemas (`acme_api/schemas/`) mirroring each model for API serialization with validators (domain format, RFC-compliant expiry parsing). + +**Acceptance:** All models create/migrate cleanly; CRUD operations on every entity work via async session; schemas serialize/deserialize round-trip. + +--- + +## Phase 3 — ACME Backend Abstraction & acme.sh Integration + +**Goal:** Clean backend interface (`AcmeBackend` protocol) with a concrete `acmesh_backend.py` implementation wrapping the `acme.sh` CLI. The public API remains independent of the backend (per outline architecture). + +- `acme_api/backend/protocol.py`: + - `AcmeBackend` Protocol: `register_account()`, `issue_certificate()`, `renew_certificate()`, `get_certificate_expiry()`. + - Return types are domain-model agnostic (dict or dataclass) — the API layer maps to models. +- `acme_api/backend/acmesh_backend.py`: + - Subprocess wrapper around `acme.sh` with configurable binary path. + - DNS-01 via `--dns` flag; DNS persist mode (`--dnssleep`, `--force`). + - Account management: `--register --nocaptcha`. + - Certificate issuance/renewal: maps to acme.sh issue / renew subcommands. + - Parses output (log or stdout) for expiry dates, cert paths. + - Error handling: distinguishes transient failures (DNS propagation) from terminal errors (account invalid). +- Configuration-driven ACME home directory (`/acmesh` in container) mounted persistently. + +**Acceptance:** Backend can register an account against LE staging; issue and renew a certificate with DNS-01; parse expiry dates correctly. Mock backend available for tests without acme.sh installed. + +--- + +## Phase 4 — Core API Endpoints (Certificates, Accounts, Providers, Events) + +**Goal:** Full CRUD REST API backed by the database layer and ACME backend abstraction. OpenAPI metadata on all endpoints per outline spec. + +### Certificates (`/v1/certificates`) +- `POST /v1/certificates` — create certificate request (name, domains, acme_account, dns_provider, key_algorithm). Transitions: Pending → Issuing → Valid or Failed. Triggers immediate issuance via backend. +- `GET /v1/certificates` — list with pagination (`?offset=&limit=`), filter by status, domain search. +- `GET /v1/certificates/{id}` — single certificate detail including expiry and status. +- `DELETE /v1/certificates/{id}` — soft delete (mark as Revoked; remove from renewal schedule). +- `POST /v1/certificates/{id}/renew` — manual renewal trigger. + +### Accounts (`/v1/accounts`) +- `GET /v1/accounts` — list configured ACME accounts. + +### Providers (`/v1/providers`) +- `GET /v1/providers` — list configured DNS providers. + +### Events (`/v1/events`) +- `GET /v1/events` — query audit/event log with filtering by type, certificate, time range. + +### Implementation Details +- FastAPI router structure: `acme_api/routes/certificates.py`, `accounts.py`, `providers.py`, `events.py`. +- Dependency injection for DB sessions and backend instances. +- OpenAPI metadata on all endpoints (tags, summary, responses). + +**Acceptance:** All endpoints respond with correct status codes; input validation via Pydantic schemas; database CRUD wired end-to-end; OpenAPI docs at `/docs` reflect the API. + +--- + +## Phase 5 — Certificate Filesystem Deployment + +**Goal:** Atomic deployment of certificate artifacts to the shared filesystem (`/certificates//`). + +- `acme_api/deployer.py`: + - On successful issuance/renewal, writes cert files atomically: + 1. Write `.pem.tmp` files to a temp directory in the same filesystem. + 2. `os.fsync()` each file handle. + 3. `os.rename()` (atomic on POSIX) to final paths. + 4. Emit webhook event (Phase 7 hook). + - File layout per domain: + ``` + /certificates// + cert.pem # server certificate + chain.pem # CA chain + fullchain.pem # cert + chain concatenated + privkey.pem # private key + metadata.json # API-generated metadata (issuer, expiry, domains) + ``` + - SAN certificates: deploy under the first domain listed; symlink or additional entries for other domains if needed. + +**Acceptance:** Deployment produces correct file layout; atomic rename guarantees consumers never see partial writes; filesystem permissions are set correctly (`0644` for certs, `0600` for keys). + +--- + +## Phase 6 — Renewal Scheduler + +**Goal:** Automatic renewal of certificates before expiry using APScheduler. State tracked internally per outline (Pending → Issuing → Valid → Renewing → Valid/Failed). + +- `acme_api/scheduler.py`: + - On certificate creation/update: schedule next run based on `expiry_date` minus configured window (default 30 days, configurable via config.yaml). + - Job stores the certificate ID; looks up latest state at execution time. + - State transitions during renewal: Valid → Renewing → Valid or Failed. + - Retry policy: configurable retries with exponential backoff for transient failures. + - Graceful shutdown: scheduler pauses jobs on SIGTERM, waits for in-flight renewals to complete (configurable timeout). +- Scheduler initialized at app startup; persisted job state not required (jobs reconstructed from DB on restart — any cert expiring within the window is rescheduled immediately). + +**Acceptance:** Certificates within renewal window are picked up on startup; scheduled jobs execute and trigger backend renewal; failures logged and reflected in certificate status. + +--- + +## Phase 7 — Webhook Notifications + +**Goal:** HTTP webhook delivery for all lifecycle events with HMAC signing and retries. Events per outline: `certificate.created`, `.issued`, `.renewed`, `.failed`, `.expiring`, `.revoked`. + +- `acme_api/webhooks.py`: + - Payload structure per outline spec (event, certificate name, expiry, domains). + - Per-webhook HMAC-SHA256 signature in `X-Webhook-Signature` header. + - Async HTTP delivery via HTTPX with timeout and retry logic (configurable: max retries, backoff). + - Failed deliveries logged to the Event table for auditability. + +**Acceptance:** Webhooks fire on all lifecycle events; payload matches spec; HMAC signature verifiable by consumer; failed deliveries retried and logged. + +--- + +## Phase 8 — Authentication & Authorization (API Keys) + +**Goal:** API key-based auth with role-based access control (Admin, Operator, Read Only). Per outline: v1 supports API Keys only. + +- `acme_api/auth/`: + - **APIKey model**: id, name, hashed_key, role (`admin`, `operator`, `readonly`), created_at, expires_at (nullable). + - For v1, keys defined in `config.yaml` are sufficient; the DB model is prepared for future API-managed key lifecycle. + - **Middleware**: validates `Authorization: Bearer ` header; extracts role from config or DB lookup. + - **RBAC enforcement** via FastAPI dependencies: + - Admin: all endpoints (CRUD on certificates, accounts, providers, webhooks). + - Operator: create/renew certificates, view status, view events. + - Read Only: GET endpoints only. + - API key hashing via Passlib + bcrypt for storage. + +**Acceptance:** Unauthenticated requests return 401; insufficient role returns 403; all roles can access their permitted endpoints. + +--- + +## Phase 9 — Metrics & Health/Readiness Checks + +**Goal:** Prometheus metrics endpoint and Kubernetes-ready health probes per outline spec. + +- `acme_api/metrics.py`: + - Prometheus Client SDK integration. + - Counters: `certificates_total`, `renewals_total`, `renewals_failed_total`, `webhook_deliveries_total`, `webhook_failures_total`. + - Gauge: `certificates_expiring` (count of certs expiring within N days). + - Metrics endpoint at `/metrics`. +- Health/Readiness endpoints per outline: + - `GET /health` — always returns 200 with uptime. + - `GET /ready` — checks DB connectivity and acme.sh binary availability; returns 503 if any dependency is down. + +**Acceptance:** `/metrics` exposes all defined metrics in Prometheus format; `/health` responds 200 on startup; `/ready` reflects actual dependency state. + +--- + +## Phase 10 — Docker Container & Deployment + +**Goal:** Production-ready container image with multi-stage build and persistent volumes (`/data`, `/certificates`, `/acmesh`) per outline spec. + +- `Dockerfile`: + - Multi-stage: builder (install deps) → runner (copy artifacts). + - Based on Python 3.14 slim image. + - Installs acme.sh into the container (script runs on first start if not present). + - Non-root user (`acmeapi`). +- `docker-compose.yml`: + - Service definition with volumes: + - `/data` — SQLite database. + - `/certificates` — deployed certificate artifacts. + - `/acmesh` — acme.sh state directory (accounts, DNS records). + - Health check configured against `/health`. + - Environment variable support for config path override. + +**Acceptance:** `make build start` produces a running container; health endpoint accessible; volumes persist data across restarts; certificates are deployed to the mounted filesystem. + +--- + +## Phase 11 — OpenAPI Documentation & Final Polish + +**Goal:** Complete API documentation and project polish. Outline: "The REST API should be fully described using OpenAPI." + +- FastAPI auto-generates OpenAPI spec at `/openapi.json`; Swagger UI at `/docs`. +- Ensure all endpoints have proper tags, descriptions, response models, and error schemas (400, 401, 403, 404, 422, 500). +- `README.md` updated with installation, configuration, API overview, and deployment instructions. +- Final pass on linting, type-checking, formatting (`make combined-check`). + +**Acceptance:** OpenAPI docs are comprehensive; Swagger UI interactive; all quality gates pass at 80%+ coverage per file; mypy strict mode clean. + +--- + +## Phase 12 — Integration Tests & End-to-End Verification + +**Goal:** Validate the full system with integration tests covering real-world flows. + +- `tests/integration/`: + - **Full certificate lifecycle**: create → issue → deploy → renew → revoke, using acme.sh against LE staging (or a mock ACME server like Pebble). + - **Renewal scheduling**: cert expiring soon is picked up by scheduler and renewed. + - **Webhook delivery**: events fire and are delivered to a test HTTP endpoint. + - **Auth flows**: all roles tested against all endpoints for correct RBAC. + - **Docker smoke test**: container starts, health checks pass, API responds. +- Fixtures in `tests/fixtures/`: sample config.yaml, mock DNS provider env files, test certificate data. + +**Acceptance:** Integration tests run via `make test`; coverage gate met; E2E flow produces a real (staging) certificate deployed to the filesystem. + +--- + +## Dependency Graph & Parallelism + +Phases with no hard dependencies can be worked in parallel: + +``` +Phase 0 ──┐ + ├──> Phase 1 ──> Phase 2 ──┐ + │ ├──> Phase 3 ──> Phase 4 + │ │ + └──────────────────────────┘ + │ + Phase 5 │ (parallel with Phase 6) +Phase 5 ──────────────────────/ │ Phase 6 ────────────────\ + ├──> Phase 7 ──────────────\ + │ + Phase 8 ──────────────────────┤ + ├──> Phase 9 + Phase 10 (can start after 4) │ + │ +Phase 11 ←────────────────────────────────────────────────────────/ + \ +Phase 12 ←──────────────────────────────────────────────────────────-/ +``` + +**Strict ordering:** +- Phase 0 → Phase 1 → Phase 2 (config and DB must exist before anything else). +- Phase 2 + Phase 3 → Phase 4 (API needs both DB models and backend abstraction). +- Phase 4 → Phase 5, 6, 7 (deployment, scheduling, webhooks all act on certificates created by the API). +- Phase 5–9 can proceed in parallel once Phase 4 is complete. +- Phase 10 depends on a working application (Phase 4+). +- Phase 11 and 12 are final polish — depend on everything else. + +--- + +## Testing Strategy Per Phase + +| Phase | Test Type | Coverage Target | +|-------|-----------|-----------------| +| 0 | Unit: entry point, config parse | 80% | +| 1 | Unit: config validation, log formatting | 80% | +| 2 | Unit + Integration: ORM CRUD, migrations, schema validation | 90% | +| 3 | Unit (mock subprocess) + Integration (staging acme.sh) | 80% / 70% | +| 4 | Integration: API endpoints via TestClient | 85% | +| 5 | Unit: atomic write, permissions, metadata JSON | 90% | +| 6 | Unit (mock backend): scheduling logic, state transitions | 90% | +| 7 | Unit (mock HTTPX): payload construction, HMAC, retry | 85% | +| 8 | Integration: auth middleware, RBAC matrix | 90% | +| 9 | Unit: metric counters, health checks | 85% | +| 10 | Smoke test: container build + startup | — | +| 11 | Regression: full `make combined-check` | 80% | +| 12 | E2E: full lifecycle against staging ACME | 70% | + +--- + +## Risk & Mitigation + +| Risk | Impact | Mitigation | +|------|--------|------------| +| acme.sh subprocess flakiness (DNS propagation) | False test failures | Mock backend for unit tests; Pebble/staging for integration with generous timeouts | +| Atomic deploy on non-POSIX FS | Partial cert visible to consumers | Test with `os.rename` semantics; fallback to copy+rename if needed | +| SQLite concurrency under load | Write conflicts | WAL mode enabled; connection pooling configured conservatively | +| API key rotation without downtime | Auth outage during transition | Support multiple active keys; soft-delete old keys | From 4664b751ca4f9de907ab8405cb315766d2fd6947 Mon Sep 17 00:00:00 2001 From: Streaky Date: Wed, 1 Jul 2026 03:53:39 +0100 Subject: [PATCH 03/26] phase 0 --- .env | 1 + .gitignore | 4 + acme_api/__init__.py | 1 + acme_api/config.py | 98 ++++++++++++ acme_api/main.py | 90 +++++++++++ coverage-data/coverage.json | 1 + coverage-data/coverage.xml | 295 ++++++++++++++++++++++++++++++++++++ pyproject.toml | 24 ++- requirements-dev.txt | 145 +++++++++++++++++- requirements.txt | 85 +++++++++++ scripts/check_max_lines.py | 36 +++++ tests/conftest.py | 42 +++++ tests/test_config.py | 134 ++++++++++++++++ tests/test_health.py | 13 ++ tests/test_main.py | 86 +++++++++++ 15 files changed, 1049 insertions(+), 6 deletions(-) create mode 100644 .env create mode 100644 acme_api/__init__.py create mode 100644 acme_api/config.py create mode 100644 acme_api/main.py create mode 100644 coverage-data/coverage.json create mode 100644 coverage-data/coverage.xml create mode 100644 scripts/check_max_lines.py create mode 100644 tests/conftest.py create mode 100644 tests/test_config.py create mode 100644 tests/test_health.py create mode 100644 tests/test_main.py diff --git a/.env b/.env new file mode 100644 index 0000000..3c3a310 --- /dev/null +++ b/.env @@ -0,0 +1 @@ +PYTEST_COV = --cov=acme_api --cov=tests diff --git a/.gitignore b/.gitignore index d50c4ec..51bdca7 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,7 @@ .venv/ .pycache/ .mypy_cache/ +__pycache__ + +coverage_data/ +.coverage diff --git a/acme_api/__init__.py b/acme_api/__init__.py new file mode 100644 index 0000000..12cba3a --- /dev/null +++ b/acme_api/__init__.py @@ -0,0 +1 @@ +"""acme.api — Lightweight ACME certificate management REST service.""" diff --git a/acme_api/config.py b/acme_api/config.py new file mode 100644 index 0000000..ba73559 --- /dev/null +++ b/acme_api/config.py @@ -0,0 +1,98 @@ +"""Runtime configuration loader. + +Reads ``config.yaml`` and validates it against a Pydantic schema. +Path is taken from the ``ACME_API_CONFIG`` environment variable or falls back to +``./config.yaml`` in the working directory. +""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any, Literal + +import yaml +from pydantic import BaseModel, Field + + +class LogConfig(BaseModel): + """Logging configuration.""" + + level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO" + format: Literal["json", "text"] = "json" + + +class DatabaseConfig(BaseModel): + """SQLite database configuration.""" + + url: str = "sqlite+aiosqlite:///./data/acme.db" + pool_size: int = Field(default=5, ge=1) + + +class DeploymentConfig(BaseModel): + """Certificate filesystem deployment configuration.""" + + directory: Path = Path("/certificates") + permissions_cert: int = 0o644 + permissions_key: int = 0o600 + + +class AcmeConfig(BaseModel): + """acme.sh binary and state directory configuration.""" + + binary_path: str = "/usr/local/bin/acme.sh" + home_dir: Path = Path("/acmesh") + + +class RenewalConfig(BaseModel): + """Automatic renewal scheduling configuration.""" + + window_days: int = Field(default=30, ge=1) + max_retries: int = Field(default=3, ge=0) + + +class AppSettings(BaseModel): + """Top-level application settings loaded from config.yaml. + + Attributes: + log: Logging level and format. + database: SQLite connection configuration. + deployment: Where certificates are written on disk. + acme: Path to the acme.sh binary and its state directory. + renewal: Scheduling parameters for automatic renewals. + """ + + log: LogConfig = Field(default_factory=LogConfig) + database: DatabaseConfig = Field(default_factory=DatabaseConfig) + deployment: DeploymentConfig = Field(default_factory=DeploymentConfig) + acme: AcmeConfig = Field(default_factory=AcmeConfig) + renewal: RenewalConfig = Field(default_factory=RenewalConfig) + + +def load_config(path: Path | None = None) -> AppSettings: + """Load and validate configuration from a YAML file. + + Args: + path: Override for the config file path. Falls back to the + ``ACME_API_CONFIG`` environment variable or ``./config.yaml``. + + Returns: + A validated :class:`AppSettings` instance. + + Raises: + FileNotFoundError: When no config file can be located. + ValueError: When the YAML content fails schema validation. + """ + if path is None: + env_path = os.environ.get("ACME_API_CONFIG") + if env_path: + path = Path(env_path) + else: + path = Path("./config.yaml") + + raw: dict[str, Any] = {} + if path.exists(): + with open(path, encoding="utf-8") as fh: + raw = yaml.safe_load(fh) or {} + + return AppSettings(**raw) diff --git a/acme_api/main.py b/acme_api/main.py new file mode 100644 index 0000000..fd78166 --- /dev/null +++ b/acme_api/main.py @@ -0,0 +1,90 @@ +"""FastAPI application factory and CLI entry point. + +Provides ``create_app`` for dependency-injection wiring and a ``main()`` +CLI function that launches uvicorn via the ``acme-api`` console script. +""" + +from __future__ import annotations + +import logging +from contextlib import asynccontextmanager +from typing import AsyncGenerator + +import uvicorn +from fastapi import FastAPI, Request, Response +from fastapi.responses import JSONResponse + +from acme_api.config import AppSettings, load_config + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncGenerator[None]: + """Application lifespan handler. + + Starts structured logging on startup; nothing to tear down yet. + """ + settings = app.state.settings + log_level_name: str = getattr(settings.log, "level", "INFO") + logging.basicConfig(level=getattr(logging, log_level_name), format="%(asctime)s %(levelname)s %(message)s") + logging.info("acme.api starting up") + yield + logging.info("acme.api shutting down") + + +def create_app(settings: AppSettings | None = None) -> FastAPI: + """Create and configure the FastAPI application. + + Args: + settings: Optional pre-loaded configuration. If ``None``, config is + loaded from disk/environment at startup time via lifespan. + + Returns: + A fully configured :class:`FastAPI` instance with a health endpoint. + """ + if settings is None: + settings = load_config() + + app = FastAPI( + title="acme.api", + description=( + "Lightweight, self-hosted REST service for managing ACME certificates " + "via DNS-01 validation." + ), + version="0.1.0", + lifespan=lifespan, + ) + + app.state.settings = settings + + @app.get("/health", tags=["Health"]) + async def health() -> dict[str, str]: + """Liveness probe — always returns 200 when the process is running.""" + return {"status": "ok"} + + @app.exception_handler(Exception) + async def unhandled_exception_handler( + request: Request, _exc: Exception + ) -> Response: + logging.exception("unhandled exception on %s", request.url.path) + return JSONResponse(status_code=500, content={"detail": "internal server error"}) + + return app + + +def main() -> None: + """CLI entry point — loads config and launches uvicorn.""" + settings = load_config() + level: str = getattr(settings.log, "level", "INFO") + logging.basicConfig(level=getattr(logging, level), format="%(asctime)s %(levelname)s %(message)s") + + uvicorn.run( + "acme_api.main:create_app", + factory=True, + host="0.0.0.0", + port=8000, + log_level=level.lower(), + ) + + +if __name__ == "__main__": + main() diff --git a/coverage-data/coverage.json b/coverage-data/coverage.json new file mode 100644 index 0000000..08a30df --- /dev/null +++ b/coverage-data/coverage.json @@ -0,0 +1 @@ +{"meta": {"format": 3, "version": "7.14.3", "timestamp": "2026-07-01T03:33:30.870196", "branch_coverage": false, "show_contexts": false}, "files": {"acme_api/__init__.py": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 0, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "functions": {"": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 0, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 1}}, "classes": {"": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 0, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 1}}}, "acme_api/config.py": {"executed_lines": [8, 10, 11, 12, 14, 15, 18, 21, 22, 25, 28, 29, 32, 35, 36, 37, 40, 43, 44, 47, 50, 51, 54, 65, 66, 67, 68, 69, 72, 86, 87, 88, 89, 93, 94, 95, 96, 98], "summary": {"covered_lines": 38, "num_statements": 39, "percent_covered": 97.43589743589743, "percent_covered_display": "97", "missing_lines": 1, "excluded_lines": 0, "percent_statements_covered": 97.43589743589743, "percent_statements_covered_display": "97"}, "missing_lines": [91], "excluded_lines": [], "functions": {"load_config": {"executed_lines": [86, 87, 88, 89, 93, 94, 95, 96, 98], "summary": {"covered_lines": 9, "num_statements": 10, "percent_covered": 90.0, "percent_covered_display": "90", "missing_lines": 1, "excluded_lines": 0, "percent_statements_covered": 90.0, "percent_statements_covered_display": "90"}, "missing_lines": [91], "excluded_lines": [], "start_line": 72}, "": {"executed_lines": [8, 10, 11, 12, 14, 15, 18, 21, 22, 25, 28, 29, 32, 35, 36, 37, 40, 43, 44, 47, 50, 51, 54, 65, 66, 67, 68, 69, 72], "summary": {"covered_lines": 29, "num_statements": 29, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 1}}, "classes": {"LogConfig": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 0, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 18}, "DatabaseConfig": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 0, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 25}, "DeploymentConfig": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 0, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 32}, "AcmeConfig": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 0, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 40}, "RenewalConfig": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 0, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 47}, "AppSettings": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 0, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 54}, "": {"executed_lines": [8, 10, 11, 12, 14, 15, 18, 21, 22, 25, 28, 29, 32, 35, 36, 37, 40, 43, 44, 47, 50, 51, 54, 65, 66, 67, 68, 69, 72, 86, 87, 88, 89, 93, 94, 95, 96, 98], "summary": {"covered_lines": 38, "num_statements": 39, "percent_covered": 97.43589743589743, "percent_covered_display": "97", "missing_lines": 1, "excluded_lines": 0, "percent_statements_covered": 97.43589743589743, "percent_statements_covered_display": "97"}, "missing_lines": [91], "excluded_lines": [], "start_line": 1}}}, "acme_api/main.py": {"executed_lines": [7, 9, 10, 11, 13, 14, 15, 17, 20, 21, 26, 27, 28, 29, 30, 31, 34, 44, 47, 57, 59, 60, 62, 64, 65, 68, 69, 71, 74, 76, 77, 78, 80, 89], "summary": {"covered_lines": 34, "num_statements": 36, "percent_covered": 94.44444444444444, "percent_covered_display": "94", "missing_lines": 2, "excluded_lines": 0, "percent_statements_covered": 94.44444444444444, "percent_statements_covered_display": "94"}, "missing_lines": [45, 90], "excluded_lines": [], "functions": {"lifespan": {"executed_lines": [26, 27, 28, 29, 30, 31], "summary": {"covered_lines": 6, "num_statements": 6, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 21}, "create_app": {"executed_lines": [44, 47, 57, 59, 60, 64, 65, 71], "summary": {"covered_lines": 8, "num_statements": 9, "percent_covered": 88.88888888888889, "percent_covered_display": "89", "missing_lines": 1, "excluded_lines": 0, "percent_statements_covered": 88.88888888888889, "percent_statements_covered_display": "89"}, "missing_lines": [45], "excluded_lines": [], "start_line": 34}, "create_app.health": {"executed_lines": [62], "summary": {"covered_lines": 1, "num_statements": 1, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 60}, "create_app.unhandled_exception_handler": {"executed_lines": [68, 69], "summary": {"covered_lines": 2, "num_statements": 2, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 65}, "main": {"executed_lines": [76, 77, 78, 80], "summary": {"covered_lines": 4, "num_statements": 4, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 74}, "": {"executed_lines": [7, 9, 10, 11, 13, 14, 15, 17, 20, 21, 34, 74, 89], "summary": {"covered_lines": 13, "num_statements": 14, "percent_covered": 92.85714285714286, "percent_covered_display": "93", "missing_lines": 1, "excluded_lines": 0, "percent_statements_covered": 92.85714285714286, "percent_statements_covered_display": "93"}, "missing_lines": [90], "excluded_lines": [], "start_line": 1}}, "classes": {"": {"executed_lines": [7, 9, 10, 11, 13, 14, 15, 17, 20, 21, 26, 27, 28, 29, 30, 31, 34, 44, 47, 57, 59, 60, 62, 64, 65, 68, 69, 71, 74, 76, 77, 78, 80, 89], "summary": {"covered_lines": 34, "num_statements": 36, "percent_covered": 94.44444444444444, "percent_covered_display": "94", "missing_lines": 2, "excluded_lines": 0, "percent_statements_covered": 94.44444444444444, "percent_statements_covered_display": "94"}, "missing_lines": [45, 90], "excluded_lines": [], "start_line": 1}}}, "tests/conftest.py": {"executed_lines": [3, 5, 6, 7, 9, 10, 11, 14, 15, 19, 20, 22, 23, 25, 26, 27, 28, 30, 34, 37, 38, 40, 41, 42], "summary": {"covered_lines": 24, "num_statements": 25, "percent_covered": 96.0, "percent_covered_display": "96", "missing_lines": 1, "excluded_lines": 0, "percent_statements_covered": 96.0, "percent_statements_covered_display": "96"}, "missing_lines": [16], "excluded_lines": [], "functions": {"app": {"executed_lines": [22, 23, 25, 26, 27, 28, 30, 34], "summary": {"covered_lines": 8, "num_statements": 8, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 20}, "client": {"executed_lines": [40, 41, 42], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 38}, "": {"executed_lines": [3, 5, 6, 7, 9, 10, 11, 14, 15, 19, 20, 37, 38], "summary": {"covered_lines": 13, "num_statements": 14, "percent_covered": 92.85714285714286, "percent_covered_display": "93", "missing_lines": 1, "excluded_lines": 0, "percent_statements_covered": 92.85714285714286, "percent_statements_covered_display": "93"}, "missing_lines": [16], "excluded_lines": [], "start_line": 1}}, "classes": {"": {"executed_lines": [3, 5, 6, 7, 9, 10, 11, 14, 15, 19, 20, 22, 23, 25, 26, 27, 28, 30, 34, 37, 38, 40, 41, 42], "summary": {"covered_lines": 24, "num_statements": 25, "percent_covered": 96.0, "percent_covered_display": "96", "missing_lines": 1, "excluded_lines": 0, "percent_statements_covered": 96.0, "percent_statements_covered_display": "96"}, "missing_lines": [16], "excluded_lines": [], "start_line": 1}}}, "tests/test_config.py": {"executed_lines": [3, 5, 7, 8, 10, 21, 22, 23, 24, 25, 27, 28, 29, 30, 33, 34, 35, 36, 37, 40, 41, 42, 43, 45, 46, 47, 50, 51, 52, 53, 54, 57, 58, 59, 60, 62, 63, 64, 65, 68, 69, 70, 71, 72, 73, 75, 76, 77, 79, 82, 83, 85, 86, 88, 89, 90, 94, 95, 97, 98, 99, 101, 102, 103, 104, 106, 107, 108, 110, 112, 113, 114, 115, 116, 118, 119, 120, 121, 123, 124, 126, 127, 128, 129, 132, 133, 134], "summary": {"covered_lines": 87, "num_statements": 87, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "functions": {"TestLogConfig.test_defaults": {"executed_lines": [23, 24, 25], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 22}, "TestLogConfig.test_custom_level": {"executed_lines": [28, 29, 30], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 27}, "TestDatabaseConfig.test_defaults": {"executed_lines": [35, 36, 37], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 34}, "TestDeploymentConfig.test_defaults": {"executed_lines": [42, 43], "summary": {"covered_lines": 2, "num_statements": 2, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 41}, "TestDeploymentConfig.test_custom_directory": {"executed_lines": [46, 47], "summary": {"covered_lines": 2, "num_statements": 2, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 45}, "TestAcmeConfig.test_defaults": {"executed_lines": [52, 53, 54], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 51}, "TestRenewalConfig.test_defaults": {"executed_lines": [59, 60], "summary": {"covered_lines": 2, "num_statements": 2, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 58}, "TestRenewalConfig.test_custom_window": {"executed_lines": [63, 64, 65], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 62}, "TestAppSettings.test_full_defaults": {"executed_lines": [70, 71, 72, 73], "summary": {"covered_lines": 4, "num_statements": 4, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 69}, "TestAppSettings.test_partial_override": {"executed_lines": [76, 77, 79], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 75}, "TestLoadConfig.test_missing_file_returns_defaults": {"executed_lines": [85, 86], "summary": {"covered_lines": 2, "num_statements": 2, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 83}, "TestLoadConfig.test_load_valid_yaml": {"executed_lines": [89, 90, 94, 95, 97, 98, 99], "summary": {"covered_lines": 7, "num_statements": 7, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 88}, "TestLoadConfig.test_env_var_path": {"executed_lines": [102, 103, 104, 106, 107, 108], "summary": {"covered_lines": 6, "num_statements": 6, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 101}, "TestLoadConfig.test_empty_yaml": {"executed_lines": [112, 113, 114, 115, 116], "summary": {"covered_lines": 5, "num_statements": 5, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 110}, "TestLoadConfig.test_invalid_log_level": {"executed_lines": [119, 120, 121, 123, 124], "summary": {"covered_lines": 5, "num_statements": 5, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 118}, "TestLoadConfig.test_fallback_to_cwd": {"executed_lines": [127, 128, 129, 132, 133, 134], "summary": {"covered_lines": 6, "num_statements": 6, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 126}, "": {"executed_lines": [3, 5, 7, 8, 10, 21, 22, 27, 33, 34, 40, 41, 45, 50, 51, 57, 58, 62, 68, 69, 75, 82, 83, 88, 101, 110, 118, 126], "summary": {"covered_lines": 28, "num_statements": 28, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 1}}, "classes": {"TestLogConfig": {"executed_lines": [23, 24, 25, 28, 29, 30], "summary": {"covered_lines": 6, "num_statements": 6, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 21}, "TestDatabaseConfig": {"executed_lines": [35, 36, 37], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 33}, "TestDeploymentConfig": {"executed_lines": [42, 43, 46, 47], "summary": {"covered_lines": 4, "num_statements": 4, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 40}, "TestAcmeConfig": {"executed_lines": [52, 53, 54], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 50}, "TestRenewalConfig": {"executed_lines": [59, 60, 63, 64, 65], "summary": {"covered_lines": 5, "num_statements": 5, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 57}, "TestAppSettings": {"executed_lines": [70, 71, 72, 73, 76, 77, 79], "summary": {"covered_lines": 7, "num_statements": 7, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 68}, "TestLoadConfig": {"executed_lines": [85, 86, 89, 90, 94, 95, 97, 98, 99, 102, 103, 104, 106, 107, 108, 112, 113, 114, 115, 116, 119, 120, 121, 123, 124, 127, 128, 129, 132, 133, 134], "summary": {"covered_lines": 31, "num_statements": 31, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 82}, "": {"executed_lines": [3, 5, 7, 8, 10, 21, 22, 27, 33, 34, 40, 41, 45, 50, 51, 57, 58, 62, 68, 69, 75, 82, 83, 88, 101, 110, 118, 126], "summary": {"covered_lines": 28, "num_statements": 28, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 1}}}, "tests/test_health.py": {"executed_lines": [3, 5, 8, 11, 12, 13], "summary": {"covered_lines": 6, "num_statements": 6, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "functions": {"test_health_returns_ok": {"executed_lines": [11, 12, 13], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 8}, "": {"executed_lines": [3, 5, 8], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 1}}, "classes": {"": {"executed_lines": [3, 5, 8, 11, 12, 13], "summary": {"covered_lines": 6, "num_statements": 6, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 1}}}, "tests/test_main.py": {"executed_lines": [3, 5, 6, 8, 9, 11, 12, 15, 16, 17, 23, 24, 25, 26, 28, 29, 30, 31, 32, 33, 35, 36, 37, 38, 40, 42, 44, 45, 46, 48, 49, 50, 51, 54, 55, 57, 58, 60, 62, 63, 64, 66, 69, 70, 72, 73, 75, 79, 81, 83, 84, 85, 86], "summary": {"covered_lines": 53, "num_statements": 53, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "functions": {"settings": {"executed_lines": [17], "summary": {"covered_lines": 1, "num_statements": 1, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 16}, "TestCreateApp.test_returns_fastapi_instance": {"executed_lines": [25, 26], "summary": {"covered_lines": 2, "num_statements": 2, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 24}, "TestCreateApp.test_health_endpoint_ok": {"executed_lines": [29, 30, 31, 32, 33], "summary": {"covered_lines": 5, "num_statements": 5, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 28}, "TestCreateApp.test_settings_stored_on_state": {"executed_lines": [36, 37, 38], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 35}, "TestCreateApp.test_unhandled_exception_returns_500": {"executed_lines": [42, 44, 45, 48, 49, 50, 51], "summary": {"covered_lines": 7, "num_statements": 7, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 40}, "TestCreateApp.test_unhandled_exception_returns_500.boom_route": {"executed_lines": [46], "summary": {"covered_lines": 1, "num_statements": 1, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 45}, "TestLifespan.test_lifespan_yields": {"executed_lines": [57, 58, 60, 62, 63, 64, 66], "summary": {"covered_lines": 7, "num_statements": 7, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 55}, "TestMain.test_main_runs_uvicorn": {"executed_lines": [72, 73, 75, 79, 81, 83, 84, 85, 86], "summary": {"covered_lines": 9, "num_statements": 9, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 70}, "": {"executed_lines": [3, 5, 6, 8, 9, 11, 12, 15, 16, 23, 24, 28, 35, 40, 54, 55, 69, 70], "summary": {"covered_lines": 18, "num_statements": 18, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 1}}, "classes": {"TestCreateApp": {"executed_lines": [25, 26, 29, 30, 31, 32, 33, 36, 37, 38, 42, 44, 45, 46, 48, 49, 50, 51], "summary": {"covered_lines": 18, "num_statements": 18, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 23}, "TestLifespan": {"executed_lines": [57, 58, 60, 62, 63, 64, 66], "summary": {"covered_lines": 7, "num_statements": 7, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 54}, "TestMain": {"executed_lines": [72, 73, 75, 79, 81, 83, 84, 85, 86], "summary": {"covered_lines": 9, "num_statements": 9, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 69}, "": {"executed_lines": [3, 5, 6, 8, 9, 11, 12, 15, 16, 17, 23, 24, 28, 35, 40, 54, 55, 69, 70], "summary": {"covered_lines": 19, "num_statements": 19, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 1}}}}, "totals": {"covered_lines": 242, "num_statements": 246, "percent_covered": 98.3739837398374, "percent_covered_display": "98", "missing_lines": 4, "excluded_lines": 0, "percent_statements_covered": 98.3739837398374, "percent_statements_covered_display": "98"}} \ No newline at end of file diff --git a/coverage-data/coverage.xml b/coverage-data/coverage.xml new file mode 100644 index 0000000..659bcca --- /dev/null +++ b/coverage-data/coverage.xml @@ -0,0 +1,295 @@ + + + + + + /mnt/usb-data/projects/acme.api/acme_api + /mnt/usb-data/projects/acme.api/tests + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pyproject.toml b/pyproject.toml index bb9bb43..4225128 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,16 @@ classifiers = [ ] dependencies = [ "PyYAML", + "fastapi", + "uvicorn[standard]", + "sqlalchemy[asyncio]", + "aiosqlite", + "apscheduler", + "pydantic-settings", + "httpx", + "prometheus-client", + "passlib[bcrypt]", + "aiofiles", ] [project.urls] @@ -37,14 +47,15 @@ dev = [ "isort", "mypy", "pytest", + "pytest-asyncio", "pytest-cov", "pylint", "types-PyYAML", + "httpx", ] [project.scripts] -# TODO: add entry point once the main module is implemented. - +acme-api = "acme_api.main:main" [tool.setuptools] package-dir = {"" = "."} @@ -61,6 +72,13 @@ load-plugins = [] [tool.pylint."MESSAGES CONTROL"] disable = [ + "missing-class-docstring", + "missing-function-docstring", + "import-outside-toplevel", + "redefined-outer-name", + "too-few-public-methods", + "no-member", + "unspecified-encoding", ] [tool.flake8] @@ -73,7 +91,6 @@ statistics = true profile = "black" src_paths = ["acme_api", "tests"] known_first_party = ["acme_api"] - [tool.pytest.ini_options] pythonpath = ["."] filterwarnings = [] @@ -82,6 +99,7 @@ addopts = [ "--tb=short", "--cov-report=term", ] +asyncio_mode = "auto" [tool.mypy] python_version = "3.14" diff --git a/requirements-dev.txt b/requirements-dev.txt index 6725ac6..a1107c8 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -4,20 +4,88 @@ # # pip-compile --constraint=requirements.txt --extra=dev --no-strip-extras --output-file=requirements-dev.txt --pip-args='--cache-dir=.venv/pip-cache' pyproject.toml # +aiofiles==24.1.0 + # via + # -c requirements.txt + # acme.api (pyproject.toml) +aiosqlite==0.22.1 + # via + # -c requirements.txt + # acme.api (pyproject.toml) +annotated-doc==0.0.4 + # via + # -c requirements.txt + # fastapi +annotated-types==0.7.0 + # via + # -c requirements.txt + # pydantic +anyio==4.14.1 + # via + # -c requirements.txt + # httpx + # starlette + # watchfiles +apscheduler==3.11.3 + # via + # -c requirements.txt + # acme.api (pyproject.toml) ast-serialize==0.6.0 # via mypy astroid==4.0.4 # via pylint +bcrypt==5.0.0 + # via + # -c requirements.txt + # passlib black==26.5.1 # via acme.api (pyproject.toml) +certifi==2026.6.17 + # via + # -c requirements.txt + # httpcore + # httpx click==8.4.2 - # via black + # via + # -c requirements.txt + # black + # uvicorn coverage[toml]==7.14.3 # via pytest-cov dill==0.4.1 # via pylint +fastapi==0.138.2 + # via + # -c requirements.txt + # acme.api (pyproject.toml) flake8==7.3.0 # via acme.api (pyproject.toml) +greenlet==3.5.3 + # via + # -c requirements.txt + # sqlalchemy +h11==0.16.0 + # via + # -c requirements.txt + # httpcore + # uvicorn +httpcore==1.0.9 + # via + # -c requirements.txt + # httpx +httptools==0.8.0 + # via + # -c requirements.txt + # uvicorn +httpx==0.28.1 + # via + # -c requirements.txt + # acme.api (pyproject.toml) +idna==3.18 + # via + # -c requirements.txt + # anyio + # httpx iniconfig==2.3.0 # via pytest isort==8.0.1 @@ -40,6 +108,10 @@ packaging==26.2 # via # black # pytest +passlib[bcrypt]==1.7.4 + # via + # -c requirements.txt + # acme.api (pyproject.toml) pathspec==1.1.1 # via # black @@ -52,29 +124,96 @@ pluggy==1.6.0 # via # pytest # pytest-cov +prometheus-client==0.25.0 + # via + # -c requirements.txt + # acme.api (pyproject.toml) pycodestyle==2.14.0 # via flake8 +pydantic==2.13.4 + # via + # -c requirements.txt + # fastapi + # pydantic-settings +pydantic-core==2.46.4 + # via + # -c requirements.txt + # pydantic +pydantic-settings==2.14.2 + # via + # -c requirements.txt + # acme.api (pyproject.toml) pyflakes==3.4.0 # via flake8 pygments==2.20.0 # via pytest pylint==4.0.6 # via acme.api (pyproject.toml) -pytest==9.1.1 +pytest==8.4.2 # via # acme.api (pyproject.toml) + # pytest-asyncio # pytest-cov +pytest-asyncio==0.26.0 + # via acme.api (pyproject.toml) pytest-cov==7.1.0 # via acme.api (pyproject.toml) +python-dotenv==1.2.2 + # via + # -c requirements.txt + # pydantic-settings + # uvicorn pytokens==0.4.1 # via black pyyaml==6.0.3 # via # -c requirements.txt # acme.api (pyproject.toml) + # uvicorn +sqlalchemy[asyncio]==2.0.51 + # via + # -c requirements.txt + # acme.api (pyproject.toml) +starlette==1.3.1 + # via + # -c requirements.txt + # fastapi tomlkit==0.15.0 # via pylint types-pyyaml==6.0.12.20260518 # via acme.api (pyproject.toml) typing-extensions==4.15.0 - # via mypy + # via + # -c requirements.txt + # fastapi + # mypy + # pydantic + # pydantic-core + # sqlalchemy + # typing-inspection +typing-inspection==0.4.2 + # via + # -c requirements.txt + # fastapi + # pydantic + # pydantic-settings +tzlocal==5.4.4 + # via + # -c requirements.txt + # apscheduler +uvicorn[standard]==0.49.0 + # via + # -c requirements.txt + # acme.api (pyproject.toml) +uvloop==0.22.1 + # via + # -c requirements.txt + # uvicorn +watchfiles==1.2.0 + # via + # -c requirements.txt + # uvicorn +websockets==16.0 + # via + # -c requirements.txt + # uvicorn diff --git a/requirements.txt b/requirements.txt index f1218da..a7d886a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,5 +4,90 @@ # # pip-compile --output-file=requirements.txt --pip-args='--cache-dir=.venv/pip-cache' --strip-extras pyproject.toml # +aiofiles==24.1.0 + # via acme.api (pyproject.toml) +aiosqlite==0.22.1 + # via acme.api (pyproject.toml) +annotated-doc==0.0.4 + # via fastapi +annotated-types==0.7.0 + # via pydantic +anyio==4.14.1 + # via + # httpx + # starlette + # watchfiles +apscheduler==3.11.3 + # via acme.api (pyproject.toml) +bcrypt==5.0.0 + # via passlib +certifi==2026.6.17 + # via + # httpcore + # httpx +click==8.4.2 + # via uvicorn +fastapi==0.138.2 + # via acme.api (pyproject.toml) +greenlet==3.5.3 + # via sqlalchemy +h11==0.16.0 + # via + # httpcore + # uvicorn +httpcore==1.0.9 + # via httpx +httptools==0.8.0 + # via uvicorn +httpx==0.28.1 + # via acme.api (pyproject.toml) +idna==3.18 + # via + # anyio + # httpx +passlib==1.7.4 + # via acme.api (pyproject.toml) +prometheus-client==0.25.0 + # via acme.api (pyproject.toml) +pydantic==2.13.4 + # via + # fastapi + # pydantic-settings +pydantic-core==2.46.4 + # via pydantic +pydantic-settings==2.14.2 + # via acme.api (pyproject.toml) +python-dotenv==1.2.2 + # via + # pydantic-settings + # uvicorn pyyaml==6.0.3 + # via + # acme.api (pyproject.toml) + # uvicorn +sqlalchemy==2.0.51 + # via acme.api (pyproject.toml) +starlette==1.3.1 + # via fastapi +typing-extensions==4.15.0 + # via + # fastapi + # pydantic + # pydantic-core + # sqlalchemy + # typing-inspection +typing-inspection==0.4.2 + # via + # fastapi + # pydantic + # pydantic-settings +tzlocal==5.4.4 + # via apscheduler +uvicorn==0.49.0 # via acme.api (pyproject.toml) +uvloop==0.22.1 + # via uvicorn +watchfiles==1.2.0 + # via uvicorn +websockets==16.0 + # via uvicorn diff --git a/scripts/check_max_lines.py b/scripts/check_max_lines.py new file mode 100644 index 0000000..d55b0f4 --- /dev/null +++ b/scripts/check_max_lines.py @@ -0,0 +1,36 @@ +"""Check that no Python file exceeds the configured maximum line count.""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + + +def main() -> None: + parser = argparse.ArgumentParser(description="Enforce per-file line limits.") + parser.add_argument("--max-lines", type=int, required=True) + parser.add_argument("paths", nargs="+") + args = parser.parse_args() + + offenders: list[str] = [] + for root_path in args.paths: + p = Path(root_path) + if p.is_file(): + files = [p] + else: + files = sorted(p.rglob("*.py")) + for fpath in files: + line_count = sum(1 for _ in fpath.read_text(encoding="utf-8").splitlines()) + if line_count > args.max_lines: + offenders.append(f"{fpath}: {line_count} lines (max {args.max_lines})") + + if offenders: + print("Max-lines check failed:") + for msg in offenders: + print(f" {msg}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..877211b --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,42 @@ +"""Shared pytest fixtures for acme.api tests.""" + +from __future__ import annotations + +import sys +from collections.abc import AsyncGenerator +from pathlib import Path + +import pytest +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient + +# Ensure the project root is on sys.path regardless of invocation directory. +ROOT = Path(__file__).resolve().parent.parent +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + + +@pytest.fixture() +def app(tmp_path: Path) -> FastAPI: + """Create a FastAPI app with an isolated temporary database path.""" + from acme_api.config import AppSettings, DatabaseConfig, DeploymentConfig + from acme_api.main import create_app + + db_dir = tmp_path / "data" + deploy_dir = tmp_path / "certificates" + db_dir.mkdir(parents=True) + deploy_dir.mkdir() + + settings = AppSettings( + database=DatabaseConfig(url=f"sqlite+aiosqlite:///{db_dir}/acme.db"), + deployment=DeploymentConfig(directory=deploy_dir), + ) + return create_app(settings=settings) + + +@pytest.fixture() +async def client(app: FastAPI) -> AsyncGenerator[AsyncClient, None]: + """Return an async test client for the FastAPI app.""" + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + yield ac diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..3596860 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,134 @@ +"""Tests for configuration loading and validation.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml + +from acme_api.config import ( + AcmeConfig, + AppSettings, + DatabaseConfig, + DeploymentConfig, + LogConfig, + RenewalConfig, + load_config, +) + + +class TestLogConfig: + def test_defaults(self) -> None: + cfg = LogConfig() + assert cfg.level == "INFO" + assert cfg.format == "json" + + def test_custom_level(self) -> None: + cfg = LogConfig(level="DEBUG", format="text") + assert cfg.level == "DEBUG" + assert cfg.format == "text" + + +class TestDatabaseConfig: + def test_defaults(self) -> None: + cfg = DatabaseConfig() + assert cfg.url == "sqlite+aiosqlite:///./data/acme.db" + assert cfg.pool_size == 5 + + +class TestDeploymentConfig: + def test_defaults(self) -> None: + cfg = DeploymentConfig() + assert cfg.directory == Path("/certificates") + + def test_custom_directory(self) -> None: + cfg = DeploymentConfig(directory=Path("/mnt/certs")) + assert cfg.directory == Path("/mnt/certs") + + +class TestAcmeConfig: + def test_defaults(self) -> None: + cfg = AcmeConfig() + assert cfg.binary_path == "/usr/local/bin/acme.sh" + assert cfg.home_dir == Path("/acmesh") + + +class TestRenewalConfig: + def test_defaults(self) -> None: + cfg = RenewalConfig() + assert cfg.window_days == 30 + + def test_custom_window(self) -> None: + cfg = RenewalConfig(window_days=60, max_retries=5) + assert cfg.window_days == 60 + assert cfg.max_retries == 5 + + +class TestAppSettings: + def test_full_defaults(self) -> None: + settings = AppSettings() + assert settings.log.level == "INFO" + assert settings.database.url == "sqlite+aiosqlite:///./data/acme.db" + assert settings.deployment.directory == Path("/certificates") + + def test_partial_override(self) -> None: + settings = AppSettings(log=LogConfig(level="DEBUG")) + assert settings.log.level == "DEBUG" + # Other defaults still apply. + assert settings.database.url == "sqlite+aiosqlite:///./data/acme.db" + + +class TestLoadConfig: + def test_missing_file_returns_defaults(self, tmp_path: Path) -> None: + """When no config file exists at the given path, return schema defaults.""" + cfg = load_config(tmp_path / "nope.yaml") + assert isinstance(cfg, AppSettings) + + def test_load_valid_yaml(self, tmp_path: Path) -> None: + config_file = tmp_path / "config.yaml" + data = { + "log": {"level": "DEBUG"}, + "database": {"url": "sqlite+aiosqlite:///./test.db"}, + } + with open(config_file, "w") as fh: + yaml.dump(data, fh) + + cfg = load_config(config_file) + assert cfg.log.level == "DEBUG" + assert cfg.database.url == "sqlite+aiosqlite:///./test.db" + + def test_env_var_path(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + config_file = tmp_path / "env_config.yaml" + with open(config_file, "w") as fh: + yaml.dump({"renewal": {"window_days": 60}}, fh) + + monkeypatch.setenv("ACME_API_CONFIG", str(config_file)) + cfg = load_config() + assert cfg.renewal.window_days == 60 + + def test_empty_yaml(self, tmp_path: Path) -> None: + """Empty YAML (None after safe_load) falls back to defaults.""" + config_file = tmp_path / "empty.yaml" + with open(config_file, "w") as fh: + fh.write("") + cfg = load_config(config_file) + assert isinstance(cfg, AppSettings) + + def test_invalid_log_level(self, tmp_path: Path) -> None: + config_file = tmp_path / "bad.yaml" + with open(config_file, "w") as fh: + yaml.dump({"log": {"level": "TRACE"}}, fh) + + with pytest.raises(ValueError): + load_config(config_file) + + def test_fallback_to_cwd(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + config_file = tmp_path / "config.yaml" + with open(config_file, "w") as fh: + yaml.dump({"renewal": {"window_days": 15}}, fh) + + # No env var set; cwd points to the file. + monkeypatch.delenv("ACME_API_CONFIG", raising=False) + cfg = load_config(path=config_file) + assert cfg.renewal.window_days == 15 diff --git a/tests/test_health.py b/tests/test_health.py new file mode 100644 index 0000000..4b9916c --- /dev/null +++ b/tests/test_health.py @@ -0,0 +1,13 @@ +"""Health endpoint smoke test.""" + +from __future__ import annotations + +from httpx import AsyncClient + + +async def test_health_returns_ok(client: AsyncClient) -> None: + + """GET /health should return 200 with status ok.""" + response = await client.get("/health") + assert response.status_code == 200 + assert response.json() == {"status": "ok"} diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..29a752b --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,86 @@ +"""Tests for application factory and CLI entry point.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + +import pytest +from fastapi.testclient import TestClient + +from acme_api.config import AppSettings, DatabaseConfig, DeploymentConfig +from acme_api.main import create_app + + +@pytest.fixture() +def settings(tmp_path: Path) -> AppSettings: + return AppSettings( + database=DatabaseConfig(url=f"sqlite+aiosqlite:///{tmp_path}/test.db"), + deployment=DeploymentConfig(directory=tmp_path / "certs"), + ) + + +class TestCreateApp: + def test_returns_fastapi_instance(self, settings: AppSettings) -> None: + app = create_app(settings=settings) + assert app.title == "acme.api" + + def test_health_endpoint_ok(self, settings: AppSettings) -> None: + app = create_app(settings=settings) + with TestClient(app) as client: + resp = client.get("/health") + assert resp.status_code == 200 + assert resp.json() == {"status": "ok"} + + def test_settings_stored_on_state(self, settings: AppSettings) -> None: + app = create_app(settings=settings) + stored = getattr(app.state, "settings", None) + assert isinstance(stored, AppSettings) + + def test_unhandled_exception_returns_500(self, settings: AppSettings) -> None: + """Uncaught exceptions in a route return 500 with generic detail.""" + app = create_app(settings=settings) + + @app.get("/boom") + async def boom_route() -> str: + raise RuntimeError("kaboom") + + with TestClient(app, raise_server_exceptions=False) as client: + resp = client.get("/boom") + assert resp.status_code == 500 + assert "detail" in resp.json() + + +class TestLifespan: + async def test_lifespan_yields(self, settings: AppSettings) -> None: + """Verify the lifespan context manager yields once.""" + app = create_app(settings=settings) + steps = [] + + from acme_api.main import lifespan + + async with lifespan(app): + steps.append("inside") + steps.append("after") + + assert steps == ["inside", "after"] + + +class TestMain: + def test_main_runs_uvicorn(self, tmp_path: Path) -> None: + """Smoke-test that main() loads config and calls uvicorn.run.""" + config_file = tmp_path / "config.yaml" + config_file.write_text("renewal:\n window_days: 15\n") + + with ( + patch("acme_api.main.load_config", return_value=AppSettings()), + patch("uvicorn.run") as mock_run, + ): + from acme_api.main import main + + main() + + mock_run.assert_called_once() + call_kwargs = mock_run.call_args.kwargs + assert call_kwargs["factory"] is True + assert call_kwargs["port"] == 8000 From b0886f739ca7f66ac8289f2a76429783a0dc3a1b Mon Sep 17 00:00:00 2001 From: Martin Nicholls <197292+streaky@users.noreply.github.com> Date: Wed, 1 Jul 2026 06:32:47 +0100 Subject: [PATCH 04/26] Delete coverage-data directory --- coverage-data/coverage.json | 1 - coverage-data/coverage.xml | 295 ------------------------------------ 2 files changed, 296 deletions(-) delete mode 100644 coverage-data/coverage.json delete mode 100644 coverage-data/coverage.xml diff --git a/coverage-data/coverage.json b/coverage-data/coverage.json deleted file mode 100644 index 08a30df..0000000 --- a/coverage-data/coverage.json +++ /dev/null @@ -1 +0,0 @@ -{"meta": {"format": 3, "version": "7.14.3", "timestamp": "2026-07-01T03:33:30.870196", "branch_coverage": false, "show_contexts": false}, "files": {"acme_api/__init__.py": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 0, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "functions": {"": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 0, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 1}}, "classes": {"": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 0, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 1}}}, "acme_api/config.py": {"executed_lines": [8, 10, 11, 12, 14, 15, 18, 21, 22, 25, 28, 29, 32, 35, 36, 37, 40, 43, 44, 47, 50, 51, 54, 65, 66, 67, 68, 69, 72, 86, 87, 88, 89, 93, 94, 95, 96, 98], "summary": {"covered_lines": 38, "num_statements": 39, "percent_covered": 97.43589743589743, "percent_covered_display": "97", "missing_lines": 1, "excluded_lines": 0, "percent_statements_covered": 97.43589743589743, "percent_statements_covered_display": "97"}, "missing_lines": [91], "excluded_lines": [], "functions": {"load_config": {"executed_lines": [86, 87, 88, 89, 93, 94, 95, 96, 98], "summary": {"covered_lines": 9, "num_statements": 10, "percent_covered": 90.0, "percent_covered_display": "90", "missing_lines": 1, "excluded_lines": 0, "percent_statements_covered": 90.0, "percent_statements_covered_display": "90"}, "missing_lines": [91], "excluded_lines": [], "start_line": 72}, "": {"executed_lines": [8, 10, 11, 12, 14, 15, 18, 21, 22, 25, 28, 29, 32, 35, 36, 37, 40, 43, 44, 47, 50, 51, 54, 65, 66, 67, 68, 69, 72], "summary": {"covered_lines": 29, "num_statements": 29, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 1}}, "classes": {"LogConfig": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 0, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 18}, "DatabaseConfig": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 0, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 25}, "DeploymentConfig": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 0, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 32}, "AcmeConfig": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 0, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 40}, "RenewalConfig": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 0, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 47}, "AppSettings": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 0, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 54}, "": {"executed_lines": [8, 10, 11, 12, 14, 15, 18, 21, 22, 25, 28, 29, 32, 35, 36, 37, 40, 43, 44, 47, 50, 51, 54, 65, 66, 67, 68, 69, 72, 86, 87, 88, 89, 93, 94, 95, 96, 98], "summary": {"covered_lines": 38, "num_statements": 39, "percent_covered": 97.43589743589743, "percent_covered_display": "97", "missing_lines": 1, "excluded_lines": 0, "percent_statements_covered": 97.43589743589743, "percent_statements_covered_display": "97"}, "missing_lines": [91], "excluded_lines": [], "start_line": 1}}}, "acme_api/main.py": {"executed_lines": [7, 9, 10, 11, 13, 14, 15, 17, 20, 21, 26, 27, 28, 29, 30, 31, 34, 44, 47, 57, 59, 60, 62, 64, 65, 68, 69, 71, 74, 76, 77, 78, 80, 89], "summary": {"covered_lines": 34, "num_statements": 36, "percent_covered": 94.44444444444444, "percent_covered_display": "94", "missing_lines": 2, "excluded_lines": 0, "percent_statements_covered": 94.44444444444444, "percent_statements_covered_display": "94"}, "missing_lines": [45, 90], "excluded_lines": [], "functions": {"lifespan": {"executed_lines": [26, 27, 28, 29, 30, 31], "summary": {"covered_lines": 6, "num_statements": 6, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 21}, "create_app": {"executed_lines": [44, 47, 57, 59, 60, 64, 65, 71], "summary": {"covered_lines": 8, "num_statements": 9, "percent_covered": 88.88888888888889, "percent_covered_display": "89", "missing_lines": 1, "excluded_lines": 0, "percent_statements_covered": 88.88888888888889, "percent_statements_covered_display": "89"}, "missing_lines": [45], "excluded_lines": [], "start_line": 34}, "create_app.health": {"executed_lines": [62], "summary": {"covered_lines": 1, "num_statements": 1, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 60}, "create_app.unhandled_exception_handler": {"executed_lines": [68, 69], "summary": {"covered_lines": 2, "num_statements": 2, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 65}, "main": {"executed_lines": [76, 77, 78, 80], "summary": {"covered_lines": 4, "num_statements": 4, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 74}, "": {"executed_lines": [7, 9, 10, 11, 13, 14, 15, 17, 20, 21, 34, 74, 89], "summary": {"covered_lines": 13, "num_statements": 14, "percent_covered": 92.85714285714286, "percent_covered_display": "93", "missing_lines": 1, "excluded_lines": 0, "percent_statements_covered": 92.85714285714286, "percent_statements_covered_display": "93"}, "missing_lines": [90], "excluded_lines": [], "start_line": 1}}, "classes": {"": {"executed_lines": [7, 9, 10, 11, 13, 14, 15, 17, 20, 21, 26, 27, 28, 29, 30, 31, 34, 44, 47, 57, 59, 60, 62, 64, 65, 68, 69, 71, 74, 76, 77, 78, 80, 89], "summary": {"covered_lines": 34, "num_statements": 36, "percent_covered": 94.44444444444444, "percent_covered_display": "94", "missing_lines": 2, "excluded_lines": 0, "percent_statements_covered": 94.44444444444444, "percent_statements_covered_display": "94"}, "missing_lines": [45, 90], "excluded_lines": [], "start_line": 1}}}, "tests/conftest.py": {"executed_lines": [3, 5, 6, 7, 9, 10, 11, 14, 15, 19, 20, 22, 23, 25, 26, 27, 28, 30, 34, 37, 38, 40, 41, 42], "summary": {"covered_lines": 24, "num_statements": 25, "percent_covered": 96.0, "percent_covered_display": "96", "missing_lines": 1, "excluded_lines": 0, "percent_statements_covered": 96.0, "percent_statements_covered_display": "96"}, "missing_lines": [16], "excluded_lines": [], "functions": {"app": {"executed_lines": [22, 23, 25, 26, 27, 28, 30, 34], "summary": {"covered_lines": 8, "num_statements": 8, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 20}, "client": {"executed_lines": [40, 41, 42], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 38}, "": {"executed_lines": [3, 5, 6, 7, 9, 10, 11, 14, 15, 19, 20, 37, 38], "summary": {"covered_lines": 13, "num_statements": 14, "percent_covered": 92.85714285714286, "percent_covered_display": "93", "missing_lines": 1, "excluded_lines": 0, "percent_statements_covered": 92.85714285714286, "percent_statements_covered_display": "93"}, "missing_lines": [16], "excluded_lines": [], "start_line": 1}}, "classes": {"": {"executed_lines": [3, 5, 6, 7, 9, 10, 11, 14, 15, 19, 20, 22, 23, 25, 26, 27, 28, 30, 34, 37, 38, 40, 41, 42], "summary": {"covered_lines": 24, "num_statements": 25, "percent_covered": 96.0, "percent_covered_display": "96", "missing_lines": 1, "excluded_lines": 0, "percent_statements_covered": 96.0, "percent_statements_covered_display": "96"}, "missing_lines": [16], "excluded_lines": [], "start_line": 1}}}, "tests/test_config.py": {"executed_lines": [3, 5, 7, 8, 10, 21, 22, 23, 24, 25, 27, 28, 29, 30, 33, 34, 35, 36, 37, 40, 41, 42, 43, 45, 46, 47, 50, 51, 52, 53, 54, 57, 58, 59, 60, 62, 63, 64, 65, 68, 69, 70, 71, 72, 73, 75, 76, 77, 79, 82, 83, 85, 86, 88, 89, 90, 94, 95, 97, 98, 99, 101, 102, 103, 104, 106, 107, 108, 110, 112, 113, 114, 115, 116, 118, 119, 120, 121, 123, 124, 126, 127, 128, 129, 132, 133, 134], "summary": {"covered_lines": 87, "num_statements": 87, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "functions": {"TestLogConfig.test_defaults": {"executed_lines": [23, 24, 25], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 22}, "TestLogConfig.test_custom_level": {"executed_lines": [28, 29, 30], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 27}, "TestDatabaseConfig.test_defaults": {"executed_lines": [35, 36, 37], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 34}, "TestDeploymentConfig.test_defaults": {"executed_lines": [42, 43], "summary": {"covered_lines": 2, "num_statements": 2, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 41}, "TestDeploymentConfig.test_custom_directory": {"executed_lines": [46, 47], "summary": {"covered_lines": 2, "num_statements": 2, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 45}, "TestAcmeConfig.test_defaults": {"executed_lines": [52, 53, 54], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 51}, "TestRenewalConfig.test_defaults": {"executed_lines": [59, 60], "summary": {"covered_lines": 2, "num_statements": 2, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 58}, "TestRenewalConfig.test_custom_window": {"executed_lines": [63, 64, 65], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 62}, "TestAppSettings.test_full_defaults": {"executed_lines": [70, 71, 72, 73], "summary": {"covered_lines": 4, "num_statements": 4, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 69}, "TestAppSettings.test_partial_override": {"executed_lines": [76, 77, 79], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 75}, "TestLoadConfig.test_missing_file_returns_defaults": {"executed_lines": [85, 86], "summary": {"covered_lines": 2, "num_statements": 2, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 83}, "TestLoadConfig.test_load_valid_yaml": {"executed_lines": [89, 90, 94, 95, 97, 98, 99], "summary": {"covered_lines": 7, "num_statements": 7, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 88}, "TestLoadConfig.test_env_var_path": {"executed_lines": [102, 103, 104, 106, 107, 108], "summary": {"covered_lines": 6, "num_statements": 6, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 101}, "TestLoadConfig.test_empty_yaml": {"executed_lines": [112, 113, 114, 115, 116], "summary": {"covered_lines": 5, "num_statements": 5, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 110}, "TestLoadConfig.test_invalid_log_level": {"executed_lines": [119, 120, 121, 123, 124], "summary": {"covered_lines": 5, "num_statements": 5, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 118}, "TestLoadConfig.test_fallback_to_cwd": {"executed_lines": [127, 128, 129, 132, 133, 134], "summary": {"covered_lines": 6, "num_statements": 6, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 126}, "": {"executed_lines": [3, 5, 7, 8, 10, 21, 22, 27, 33, 34, 40, 41, 45, 50, 51, 57, 58, 62, 68, 69, 75, 82, 83, 88, 101, 110, 118, 126], "summary": {"covered_lines": 28, "num_statements": 28, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 1}}, "classes": {"TestLogConfig": {"executed_lines": [23, 24, 25, 28, 29, 30], "summary": {"covered_lines": 6, "num_statements": 6, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 21}, "TestDatabaseConfig": {"executed_lines": [35, 36, 37], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 33}, "TestDeploymentConfig": {"executed_lines": [42, 43, 46, 47], "summary": {"covered_lines": 4, "num_statements": 4, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 40}, "TestAcmeConfig": {"executed_lines": [52, 53, 54], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 50}, "TestRenewalConfig": {"executed_lines": [59, 60, 63, 64, 65], "summary": {"covered_lines": 5, "num_statements": 5, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 57}, "TestAppSettings": {"executed_lines": [70, 71, 72, 73, 76, 77, 79], "summary": {"covered_lines": 7, "num_statements": 7, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 68}, "TestLoadConfig": {"executed_lines": [85, 86, 89, 90, 94, 95, 97, 98, 99, 102, 103, 104, 106, 107, 108, 112, 113, 114, 115, 116, 119, 120, 121, 123, 124, 127, 128, 129, 132, 133, 134], "summary": {"covered_lines": 31, "num_statements": 31, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 82}, "": {"executed_lines": [3, 5, 7, 8, 10, 21, 22, 27, 33, 34, 40, 41, 45, 50, 51, 57, 58, 62, 68, 69, 75, 82, 83, 88, 101, 110, 118, 126], "summary": {"covered_lines": 28, "num_statements": 28, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 1}}}, "tests/test_health.py": {"executed_lines": [3, 5, 8, 11, 12, 13], "summary": {"covered_lines": 6, "num_statements": 6, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "functions": {"test_health_returns_ok": {"executed_lines": [11, 12, 13], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 8}, "": {"executed_lines": [3, 5, 8], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 1}}, "classes": {"": {"executed_lines": [3, 5, 8, 11, 12, 13], "summary": {"covered_lines": 6, "num_statements": 6, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 1}}}, "tests/test_main.py": {"executed_lines": [3, 5, 6, 8, 9, 11, 12, 15, 16, 17, 23, 24, 25, 26, 28, 29, 30, 31, 32, 33, 35, 36, 37, 38, 40, 42, 44, 45, 46, 48, 49, 50, 51, 54, 55, 57, 58, 60, 62, 63, 64, 66, 69, 70, 72, 73, 75, 79, 81, 83, 84, 85, 86], "summary": {"covered_lines": 53, "num_statements": 53, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "functions": {"settings": {"executed_lines": [17], "summary": {"covered_lines": 1, "num_statements": 1, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 16}, "TestCreateApp.test_returns_fastapi_instance": {"executed_lines": [25, 26], "summary": {"covered_lines": 2, "num_statements": 2, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 24}, "TestCreateApp.test_health_endpoint_ok": {"executed_lines": [29, 30, 31, 32, 33], "summary": {"covered_lines": 5, "num_statements": 5, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 28}, "TestCreateApp.test_settings_stored_on_state": {"executed_lines": [36, 37, 38], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 35}, "TestCreateApp.test_unhandled_exception_returns_500": {"executed_lines": [42, 44, 45, 48, 49, 50, 51], "summary": {"covered_lines": 7, "num_statements": 7, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 40}, "TestCreateApp.test_unhandled_exception_returns_500.boom_route": {"executed_lines": [46], "summary": {"covered_lines": 1, "num_statements": 1, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 45}, "TestLifespan.test_lifespan_yields": {"executed_lines": [57, 58, 60, 62, 63, 64, 66], "summary": {"covered_lines": 7, "num_statements": 7, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 55}, "TestMain.test_main_runs_uvicorn": {"executed_lines": [72, 73, 75, 79, 81, 83, 84, 85, 86], "summary": {"covered_lines": 9, "num_statements": 9, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 70}, "": {"executed_lines": [3, 5, 6, 8, 9, 11, 12, 15, 16, 23, 24, 28, 35, 40, 54, 55, 69, 70], "summary": {"covered_lines": 18, "num_statements": 18, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 1}}, "classes": {"TestCreateApp": {"executed_lines": [25, 26, 29, 30, 31, 32, 33, 36, 37, 38, 42, 44, 45, 46, 48, 49, 50, 51], "summary": {"covered_lines": 18, "num_statements": 18, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 23}, "TestLifespan": {"executed_lines": [57, 58, 60, 62, 63, 64, 66], "summary": {"covered_lines": 7, "num_statements": 7, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 54}, "TestMain": {"executed_lines": [72, 73, 75, 79, 81, 83, 84, 85, 86], "summary": {"covered_lines": 9, "num_statements": 9, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 69}, "": {"executed_lines": [3, 5, 6, 8, 9, 11, 12, 15, 16, 17, 23, 24, 28, 35, 40, 54, 55, 69, 70], "summary": {"covered_lines": 19, "num_statements": 19, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 1}}}}, "totals": {"covered_lines": 242, "num_statements": 246, "percent_covered": 98.3739837398374, "percent_covered_display": "98", "missing_lines": 4, "excluded_lines": 0, "percent_statements_covered": 98.3739837398374, "percent_statements_covered_display": "98"}} \ No newline at end of file diff --git a/coverage-data/coverage.xml b/coverage-data/coverage.xml deleted file mode 100644 index 659bcca..0000000 --- a/coverage-data/coverage.xml +++ /dev/null @@ -1,295 +0,0 @@ - - - - - - /mnt/usb-data/projects/acme.api/acme_api - /mnt/usb-data/projects/acme.api/tests - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - From 88a53a88c0f58c4b1755ce63e64153862113eff7 Mon Sep 17 00:00:00 2001 From: Streaky Date: Wed, 1 Jul 2026 15:14:03 +0100 Subject: [PATCH 05/26] Merge --- .gitignore | 2 +- acme_api/config.py | 101 ++++++++++- acme_api/main.py | 34 +++- coverage-data/coverage.json | 1 + coverage-data/coverage.xml | 348 ++++++++++++++++++++++++++++++++++++ 5 files changed, 470 insertions(+), 16 deletions(-) create mode 100644 coverage-data/coverage.json create mode 100644 coverage-data/coverage.xml diff --git a/.gitignore b/.gitignore index 51bdca7..727ace6 100644 --- a/.gitignore +++ b/.gitignore @@ -5,5 +5,5 @@ .mypy_cache/ __pycache__ -coverage_data/ +coverage-data/ .coverage diff --git a/acme_api/config.py b/acme_api/config.py index ba73559..a090b58 100644 --- a/acme_api/config.py +++ b/acme_api/config.py @@ -37,11 +37,37 @@ class DeploymentConfig(BaseModel): permissions_key: int = 0o600 -class AcmeConfig(BaseModel): - """acme.sh binary and state directory configuration.""" +class DnsProviderConfig(BaseModel): + """DNS provider alias configuration. - binary_path: str = "/usr/local/bin/acme.sh" - home_dir: Path = Path("/acmesh") + Attributes: + name: Human-readable alias (e.g. ``production``, ``staging``). + provider_name: acme.sh DNS API name (e.g. ``cloudflare``). + env_vars_file_path: Path to a file containing the DNS provider's + environment variables / credentials. + """ + + name: str = Field(min_length=1) + provider_name: str = Field(min_length=1) + env_vars_file_path: Path + + +class AcmeAccountConfig(BaseModel): + """ACME account configuration. + + Attributes: + name: Human-readable alias (e.g. ``letsencrypt-production``). + server_url: ACME directory URL (e.g. Let's Encrypt prod/staging, + ZeroSSL, Buypass). + account_key_path: Filesystem path to the account key managed by + acme.sh. Defaults to a path inside the configured home_dir. + """ + + name: str = Field(min_length=1) + server_url: str = Field( + default="https://acme-v02.api.letsencrypt.org/directory", min_length=1 + ) + account_key_path: Path | None = None class RenewalConfig(BaseModel): @@ -50,7 +76,6 @@ class RenewalConfig(BaseModel): window_days: int = Field(default=30, ge=1) max_retries: int = Field(default=3, ge=0) - class AppSettings(BaseModel): """Top-level application settings loaded from config.yaml. @@ -60,14 +85,76 @@ class AppSettings(BaseModel): deployment: Where certificates are written on disk. acme: Path to the acme.sh binary and its state directory. renewal: Scheduling parameters for automatic renewals. + dns_providers: Configured DNS provider aliases. + acme_accounts: Configured ACME account references. """ - log: LogConfig = Field(default_factory=LogConfig) database: DatabaseConfig = Field(default_factory=DatabaseConfig) deployment: DeploymentConfig = Field(default_factory=DeploymentConfig) acme: AcmeConfig = Field(default_factory=AcmeConfig) renewal: RenewalConfig = Field(default_factory=RenewalConfig) - + dns_providers: list[DnsProviderConfig] = Field(default_factory=list) + acme_accounts: list[AcmeAccountConfig] = Field(default_factory=list) + + def validate(self) -> None: + """Validate configuration at startup. + + Performs runtime checks that cannot be expressed with Pydantic field + constraints alone (cross-field references, filesystem existence, etc.). + + Raises: + ValueError: When required configuration is missing or invalid. + """ + errors = [] + + # Database URL must look like a valid DSN + if not self.database.url.startswith(("sqlite", "postgresql")): + errors.append( + f"database.url must start with 'sqlite' or 'postgresql', " + f"got: {self.database.url!r}" + ) + + # Deployment directory should exist (or be creatable) + if not self.deployment.directory.exists(): + try: + self.deployment.directory.mkdir(parents=True, exist_ok=True) + except OSError as exc: + errors.append( + f"deployment.directory '{self.deployment.directory}' " + f"does not exist and could not be created: {exc}" + ) + + # ACME home directory should exist (or be creatable) + if not self.acme.home_dir.exists(): + try: + self.acme.home_dir.mkdir(parents=True, exist_ok=True) + except OSError as exc: + errors.append( + f"acme.home_dir '{self.acme.home_dir}' " + f"does not exist and could not be created: {exc}" + ) + + # Check for duplicate DNS provider names + provider_names = [p.name for p in self.dns_providers] + if len(provider_names) != len(set(provider_names)): + errors.append("dns_providers contains duplicate 'name' values") + + # Check for duplicate ACME account names + account_names = [a.name for a in self.acme_accounts] + if len(account_names) != len(set(account_names)): + errors.append("acme_accounts contains duplicate 'name' values") + + # DNS provider env var files should exist (warn-only at startup; + # hard-fail when first used). This is informational. + for provider in self.dns_providers: + if not provider.env_vars_file_path.exists(): + errors.append( + f"dns_provider '{provider.name}': env_vars_file_path " + f"'{provider.env_vars_file_path}' does not exist" + ) + + if errors: + raise ValueError("\n".join(errors)) def load_config(path: Path | None = None) -> AppSettings: """Load and validate configuration from a YAML file. diff --git a/acme_api/main.py b/acme_api/main.py index fd78166..9d7ee5b 100644 --- a/acme_api/main.py +++ b/acme_api/main.py @@ -13,22 +13,32 @@ import uvicorn from fastapi import FastAPI, Request, Response from fastapi.responses import JSONResponse +from starlette.middleware.base import BaseHTTPMiddleware from acme_api.config import AppSettings, load_config +from acme_api.logging import setup_logging +from acme_api.middleware import RequestIdMiddleware @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncGenerator[None]: """Application lifespan handler. - Starts structured logging on startup; nothing to tear down yet. + Configures structured logging and validates settings on startup. """ settings = app.state.settings - log_level_name: str = getattr(settings.log, "level", "INFO") - logging.basicConfig(level=getattr(logging, log_level_name), format="%(asctime)s %(levelname)s %(message)s") - logging.info("acme.api starting up") + # Run config validation before anything else + settings.validate() + # Configure structured logging according to settings + setup_logging(level=settings.log.level, format_type=settings.log.format) + root_logger = logging.getLogger(__name__) + root_logger.info( + "acme.api starting up | db=%s deploy_dir=%s", + settings.database.url, + settings.deployment.directory, + ) yield - logging.info("acme.api shutting down") + root_logger.info("acme.api shutting down") def create_app(settings: AppSettings | None = None) -> FastAPI: @@ -56,6 +66,9 @@ def create_app(settings: AppSettings | None = None) -> FastAPI: app.state.settings = settings + # Middleware: request ID injection (outermost) + app.add_middleware(RequestIdMiddleware) + @app.get("/health", tags=["Health"]) async def health() -> dict[str, str]: """Liveness probe — always returns 200 when the process is running.""" @@ -74,15 +87,20 @@ async def unhandled_exception_handler( def main() -> None: """CLI entry point — loads config and launches uvicorn.""" settings = load_config() - level: str = getattr(settings.log, "level", "INFO") - logging.basicConfig(level=getattr(logging, level), format="%(asctime)s %(levelname)s %(message)s") + setup_logging(level=settings.log.level, format_type=settings.log.format) + + logging.getLogger(__name__).info( + "launching uvicorn | host=0.0.0.0 port=%s level=%s", + 8000, + settings.log.level.lower(), + ) uvicorn.run( "acme_api.main:create_app", factory=True, host="0.0.0.0", port=8000, - log_level=level.lower(), + log_level=settings.log.level.lower(), ) diff --git a/coverage-data/coverage.json b/coverage-data/coverage.json new file mode 100644 index 0000000..e1934e1 --- /dev/null +++ b/coverage-data/coverage.json @@ -0,0 +1 @@ +{"meta": {"format": 3, "version": "7.14.3", "timestamp": "2026-07-01T14:54:27.767828", "branch_coverage": false, "show_contexts": false}, "files": {"acme_api/__init__.py": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 0, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "functions": {"": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 0, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 1}}, "classes": {"": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 0, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 1}}}, "acme_api/config.py": {"executed_lines": [8, 10, 11, 12, 14, 15, 18, 21, 22, 25, 28, 29, 32, 35, 36, 37, 40, 43, 44, 47, 50, 51, 54, 65, 66, 67, 68, 69, 72, 86, 87, 88, 89, 93, 94, 95, 96, 98], "summary": {"covered_lines": 38, "num_statements": 39, "percent_covered": 97.43589743589743, "percent_covered_display": "97", "missing_lines": 1, "excluded_lines": 0, "percent_statements_covered": 97.43589743589743, "percent_statements_covered_display": "97"}, "missing_lines": [91], "excluded_lines": [], "functions": {"load_config": {"executed_lines": [86, 87, 88, 89, 93, 94, 95, 96, 98], "summary": {"covered_lines": 9, "num_statements": 10, "percent_covered": 90.0, "percent_covered_display": "90", "missing_lines": 1, "excluded_lines": 0, "percent_statements_covered": 90.0, "percent_statements_covered_display": "90"}, "missing_lines": [91], "excluded_lines": [], "start_line": 72}, "": {"executed_lines": [8, 10, 11, 12, 14, 15, 18, 21, 22, 25, 28, 29, 32, 35, 36, 37, 40, 43, 44, 47, 50, 51, 54, 65, 66, 67, 68, 69, 72], "summary": {"covered_lines": 29, "num_statements": 29, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 1}}, "classes": {"LogConfig": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 0, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 18}, "DatabaseConfig": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 0, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 25}, "DeploymentConfig": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 0, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 32}, "AcmeConfig": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 0, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 40}, "RenewalConfig": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 0, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 47}, "AppSettings": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 0, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 54}, "": {"executed_lines": [8, 10, 11, 12, 14, 15, 18, 21, 22, 25, 28, 29, 32, 35, 36, 37, 40, 43, 44, 47, 50, 51, 54, 65, 66, 67, 68, 69, 72, 86, 87, 88, 89, 93, 94, 95, 96, 98], "summary": {"covered_lines": 38, "num_statements": 39, "percent_covered": 97.43589743589743, "percent_covered_display": "97", "missing_lines": 1, "excluded_lines": 0, "percent_statements_covered": 97.43589743589743, "percent_statements_covered_display": "97"}, "missing_lines": [91], "excluded_lines": [], "start_line": 1}}}, "acme_api/logging.py": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 32, "percent_covered": 0.0, "percent_covered_display": "0", "missing_lines": 32, "excluded_lines": 0, "percent_statements_covered": 0.0, "percent_statements_covered_display": "0"}, "missing_lines": [7, 8, 9, 10, 11, 12, 15, 18, 21, 23, 31, 32, 34, 35, 37, 40, 42, 44, 45, 47, 48, 50, 51, 53, 54, 55, 57, 59, 60, 61, 62, 63], "excluded_lines": [], "functions": {"JSONFormatter.format": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 6, "percent_covered": 0.0, "percent_covered_display": "0", "missing_lines": 6, "excluded_lines": 0, "percent_statements_covered": 0.0, "percent_statements_covered_display": "0"}, "missing_lines": [23, 31, 32, 34, 35, 37], "excluded_lines": [], "start_line": 21}, "setup_logging": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 10, "percent_covered": 0.0, "percent_covered_display": "0", "missing_lines": 10, "excluded_lines": 0, "percent_statements_covered": 0.0, "percent_statements_covered_display": "0"}, "missing_lines": [42, 44, 45, 47, 48, 50, 51, 53, 54, 55], "excluded_lines": [], "start_line": 40}, "get_request_id": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 5, "percent_covered": 0.0, "percent_covered_display": "0", "missing_lines": 5, "excluded_lines": 0, "percent_statements_covered": 0.0, "percent_statements_covered_display": "0"}, "missing_lines": [59, 60, 61, 62, 63], "excluded_lines": [], "start_line": 57}, "": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 11, "percent_covered": 0.0, "percent_covered_display": "0", "missing_lines": 11, "excluded_lines": 0, "percent_statements_covered": 0.0, "percent_statements_covered_display": "0"}, "missing_lines": [7, 8, 9, 10, 11, 12, 15, 18, 21, 40, 57], "excluded_lines": [], "start_line": 1}}, "classes": {"JSONFormatter": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 6, "percent_covered": 0.0, "percent_covered_display": "0", "missing_lines": 6, "excluded_lines": 0, "percent_statements_covered": 0.0, "percent_statements_covered_display": "0"}, "missing_lines": [23, 31, 32, 34, 35, 37], "excluded_lines": [], "start_line": 18}, "": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 26, "percent_covered": 0.0, "percent_covered_display": "0", "missing_lines": 26, "excluded_lines": 0, "percent_statements_covered": 0.0, "percent_statements_covered_display": "0"}, "missing_lines": [7, 8, 9, 10, 11, 12, 15, 18, 21, 40, 42, 44, 45, 47, 48, 50, 51, 53, 54, 55, 57, 59, 60, 61, 62, 63], "excluded_lines": [], "start_line": 1}}}, "acme_api/main.py": {"executed_lines": [7, 9, 10, 11, 13, 14, 15, 17, 20, 21, 26, 27, 28, 29, 30, 31, 34, 44, 47, 57, 59, 60, 62, 64, 65, 68, 69, 71, 74, 76, 77, 78, 80, 89], "summary": {"covered_lines": 34, "num_statements": 36, "percent_covered": 94.44444444444444, "percent_covered_display": "94", "missing_lines": 2, "excluded_lines": 0, "percent_statements_covered": 94.44444444444444, "percent_statements_covered_display": "94"}, "missing_lines": [45, 90], "excluded_lines": [], "functions": {"lifespan": {"executed_lines": [26, 27, 28, 29, 30, 31], "summary": {"covered_lines": 6, "num_statements": 6, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 21}, "create_app": {"executed_lines": [44, 47, 57, 59, 60, 64, 65, 71], "summary": {"covered_lines": 8, "num_statements": 9, "percent_covered": 88.88888888888889, "percent_covered_display": "89", "missing_lines": 1, "excluded_lines": 0, "percent_statements_covered": 88.88888888888889, "percent_statements_covered_display": "89"}, "missing_lines": [45], "excluded_lines": [], "start_line": 34}, "create_app.health": {"executed_lines": [62], "summary": {"covered_lines": 1, "num_statements": 1, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 60}, "create_app.unhandled_exception_handler": {"executed_lines": [68, 69], "summary": {"covered_lines": 2, "num_statements": 2, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 65}, "main": {"executed_lines": [76, 77, 78, 80], "summary": {"covered_lines": 4, "num_statements": 4, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 74}, "": {"executed_lines": [7, 9, 10, 11, 13, 14, 15, 17, 20, 21, 34, 74, 89], "summary": {"covered_lines": 13, "num_statements": 14, "percent_covered": 92.85714285714286, "percent_covered_display": "93", "missing_lines": 1, "excluded_lines": 0, "percent_statements_covered": 92.85714285714286, "percent_statements_covered_display": "93"}, "missing_lines": [90], "excluded_lines": [], "start_line": 1}}, "classes": {"": {"executed_lines": [7, 9, 10, 11, 13, 14, 15, 17, 20, 21, 26, 27, 28, 29, 30, 31, 34, 44, 47, 57, 59, 60, 62, 64, 65, 68, 69, 71, 74, 76, 77, 78, 80, 89], "summary": {"covered_lines": 34, "num_statements": 36, "percent_covered": 94.44444444444444, "percent_covered_display": "94", "missing_lines": 2, "excluded_lines": 0, "percent_statements_covered": 94.44444444444444, "percent_statements_covered_display": "94"}, "missing_lines": [45, 90], "excluded_lines": [], "start_line": 1}}}, "acme_api/middleware.py": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 11, "percent_covered": 0.0, "percent_covered_display": "0", "missing_lines": 11, "excluded_lines": 0, "percent_statements_covered": 0.0, "percent_statements_covered_display": "0"}, "missing_lines": [7, 8, 9, 12, 15, 16, 18, 22, 24, 25, 26], "excluded_lines": [], "functions": {"RequestIdMiddleware.__init__": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 1, "percent_covered": 0.0, "percent_covered_display": "0", "missing_lines": 1, "excluded_lines": 0, "percent_statements_covered": 0.0, "percent_statements_covered_display": "0"}, "missing_lines": [16], "excluded_lines": [], "start_line": 15}, "RequestIdMiddleware.__call__": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 4, "percent_covered": 0.0, "percent_covered_display": "0", "missing_lines": 4, "excluded_lines": 0, "percent_statements_covered": 0.0, "percent_statements_covered_display": "0"}, "missing_lines": [22, 24, 25, 26], "excluded_lines": [], "start_line": 18}, "": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 6, "percent_covered": 0.0, "percent_covered_display": "0", "missing_lines": 6, "excluded_lines": 0, "percent_statements_covered": 0.0, "percent_statements_covered_display": "0"}, "missing_lines": [7, 8, 9, 12, 15, 18], "excluded_lines": [], "start_line": 1}}, "classes": {"RequestIdMiddleware": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 5, "percent_covered": 0.0, "percent_covered_display": "0", "missing_lines": 5, "excluded_lines": 0, "percent_statements_covered": 0.0, "percent_statements_covered_display": "0"}, "missing_lines": [16, 22, 24, 25, 26], "excluded_lines": [], "start_line": 12}, "": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 6, "percent_covered": 0.0, "percent_covered_display": "0", "missing_lines": 6, "excluded_lines": 0, "percent_statements_covered": 0.0, "percent_statements_covered_display": "0"}, "missing_lines": [7, 8, 9, 12, 15, 18], "excluded_lines": [], "start_line": 1}}}, "tests/conftest.py": {"executed_lines": [3, 5, 6, 7, 9, 10, 11, 14, 15, 19, 20, 22, 23, 25, 26, 27, 28, 30, 34, 37, 38, 40, 41, 42], "summary": {"covered_lines": 24, "num_statements": 25, "percent_covered": 96.0, "percent_covered_display": "96", "missing_lines": 1, "excluded_lines": 0, "percent_statements_covered": 96.0, "percent_statements_covered_display": "96"}, "missing_lines": [16], "excluded_lines": [], "functions": {"app": {"executed_lines": [22, 23, 25, 26, 27, 28, 30, 34], "summary": {"covered_lines": 8, "num_statements": 8, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 20}, "client": {"executed_lines": [40, 41, 42], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 38}, "": {"executed_lines": [3, 5, 6, 7, 9, 10, 11, 14, 15, 19, 20, 37, 38], "summary": {"covered_lines": 13, "num_statements": 14, "percent_covered": 92.85714285714286, "percent_covered_display": "93", "missing_lines": 1, "excluded_lines": 0, "percent_statements_covered": 92.85714285714286, "percent_statements_covered_display": "93"}, "missing_lines": [16], "excluded_lines": [], "start_line": 1}}, "classes": {"": {"executed_lines": [3, 5, 6, 7, 9, 10, 11, 14, 15, 19, 20, 22, 23, 25, 26, 27, 28, 30, 34, 37, 38, 40, 41, 42], "summary": {"covered_lines": 24, "num_statements": 25, "percent_covered": 96.0, "percent_covered_display": "96", "missing_lines": 1, "excluded_lines": 0, "percent_statements_covered": 96.0, "percent_statements_covered_display": "96"}, "missing_lines": [16], "excluded_lines": [], "start_line": 1}}}, "tests/test_config.py": {"executed_lines": [3, 5, 7, 8, 10, 21, 22, 23, 24, 25, 27, 28, 29, 30, 33, 34, 35, 36, 37, 40, 41, 42, 43, 45, 46, 47, 50, 51, 52, 53, 54, 57, 58, 59, 60, 62, 63, 64, 65, 68, 69, 70, 71, 72, 73, 75, 76, 77, 79, 82, 83, 85, 86, 88, 89, 90, 94, 95, 97, 98, 99, 101, 102, 103, 104, 106, 107, 108, 110, 112, 113, 114, 115, 116, 118, 119, 120, 121, 123, 124, 126, 127, 128, 129, 132, 133, 134], "summary": {"covered_lines": 87, "num_statements": 87, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "functions": {"TestLogConfig.test_defaults": {"executed_lines": [23, 24, 25], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 22}, "TestLogConfig.test_custom_level": {"executed_lines": [28, 29, 30], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 27}, "TestDatabaseConfig.test_defaults": {"executed_lines": [35, 36, 37], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 34}, "TestDeploymentConfig.test_defaults": {"executed_lines": [42, 43], "summary": {"covered_lines": 2, "num_statements": 2, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 41}, "TestDeploymentConfig.test_custom_directory": {"executed_lines": [46, 47], "summary": {"covered_lines": 2, "num_statements": 2, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 45}, "TestAcmeConfig.test_defaults": {"executed_lines": [52, 53, 54], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 51}, "TestRenewalConfig.test_defaults": {"executed_lines": [59, 60], "summary": {"covered_lines": 2, "num_statements": 2, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 58}, "TestRenewalConfig.test_custom_window": {"executed_lines": [63, 64, 65], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 62}, "TestAppSettings.test_full_defaults": {"executed_lines": [70, 71, 72, 73], "summary": {"covered_lines": 4, "num_statements": 4, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 69}, "TestAppSettings.test_partial_override": {"executed_lines": [76, 77, 79], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 75}, "TestLoadConfig.test_missing_file_returns_defaults": {"executed_lines": [85, 86], "summary": {"covered_lines": 2, "num_statements": 2, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 83}, "TestLoadConfig.test_load_valid_yaml": {"executed_lines": [89, 90, 94, 95, 97, 98, 99], "summary": {"covered_lines": 7, "num_statements": 7, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 88}, "TestLoadConfig.test_env_var_path": {"executed_lines": [102, 103, 104, 106, 107, 108], "summary": {"covered_lines": 6, "num_statements": 6, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 101}, "TestLoadConfig.test_empty_yaml": {"executed_lines": [112, 113, 114, 115, 116], "summary": {"covered_lines": 5, "num_statements": 5, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 110}, "TestLoadConfig.test_invalid_log_level": {"executed_lines": [119, 120, 121, 123, 124], "summary": {"covered_lines": 5, "num_statements": 5, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 118}, "TestLoadConfig.test_fallback_to_cwd": {"executed_lines": [127, 128, 129, 132, 133, 134], "summary": {"covered_lines": 6, "num_statements": 6, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 126}, "": {"executed_lines": [3, 5, 7, 8, 10, 21, 22, 27, 33, 34, 40, 41, 45, 50, 51, 57, 58, 62, 68, 69, 75, 82, 83, 88, 101, 110, 118, 126], "summary": {"covered_lines": 28, "num_statements": 28, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 1}}, "classes": {"TestLogConfig": {"executed_lines": [23, 24, 25, 28, 29, 30], "summary": {"covered_lines": 6, "num_statements": 6, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 21}, "TestDatabaseConfig": {"executed_lines": [35, 36, 37], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 33}, "TestDeploymentConfig": {"executed_lines": [42, 43, 46, 47], "summary": {"covered_lines": 4, "num_statements": 4, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 40}, "TestAcmeConfig": {"executed_lines": [52, 53, 54], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 50}, "TestRenewalConfig": {"executed_lines": [59, 60, 63, 64, 65], "summary": {"covered_lines": 5, "num_statements": 5, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 57}, "TestAppSettings": {"executed_lines": [70, 71, 72, 73, 76, 77, 79], "summary": {"covered_lines": 7, "num_statements": 7, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 68}, "TestLoadConfig": {"executed_lines": [85, 86, 89, 90, 94, 95, 97, 98, 99, 102, 103, 104, 106, 107, 108, 112, 113, 114, 115, 116, 119, 120, 121, 123, 124, 127, 128, 129, 132, 133, 134], "summary": {"covered_lines": 31, "num_statements": 31, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 82}, "": {"executed_lines": [3, 5, 7, 8, 10, 21, 22, 27, 33, 34, 40, 41, 45, 50, 51, 57, 58, 62, 68, 69, 75, 82, 83, 88, 101, 110, 118, 126], "summary": {"covered_lines": 28, "num_statements": 28, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 1}}}, "tests/test_health.py": {"executed_lines": [3, 5, 8, 11, 12, 13], "summary": {"covered_lines": 6, "num_statements": 6, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "functions": {"test_health_returns_ok": {"executed_lines": [11, 12, 13], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 8}, "": {"executed_lines": [3, 5, 8], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 1}}, "classes": {"": {"executed_lines": [3, 5, 8, 11, 12, 13], "summary": {"covered_lines": 6, "num_statements": 6, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 1}}}, "tests/test_main.py": {"executed_lines": [3, 5, 6, 8, 9, 11, 12, 15, 16, 17, 23, 24, 25, 26, 28, 29, 30, 31, 32, 33, 35, 36, 37, 38, 40, 42, 44, 45, 46, 48, 49, 50, 51, 54, 55, 57, 58, 60, 62, 63, 64, 66, 69, 70, 72, 73, 75, 79, 81, 83, 84, 85, 86], "summary": {"covered_lines": 53, "num_statements": 53, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "functions": {"settings": {"executed_lines": [17], "summary": {"covered_lines": 1, "num_statements": 1, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 16}, "TestCreateApp.test_returns_fastapi_instance": {"executed_lines": [25, 26], "summary": {"covered_lines": 2, "num_statements": 2, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 24}, "TestCreateApp.test_health_endpoint_ok": {"executed_lines": [29, 30, 31, 32, 33], "summary": {"covered_lines": 5, "num_statements": 5, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 28}, "TestCreateApp.test_settings_stored_on_state": {"executed_lines": [36, 37, 38], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 35}, "TestCreateApp.test_unhandled_exception_returns_500": {"executed_lines": [42, 44, 45, 48, 49, 50, 51], "summary": {"covered_lines": 7, "num_statements": 7, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 40}, "TestCreateApp.test_unhandled_exception_returns_500.boom_route": {"executed_lines": [46], "summary": {"covered_lines": 1, "num_statements": 1, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 45}, "TestLifespan.test_lifespan_yields": {"executed_lines": [57, 58, 60, 62, 63, 64, 66], "summary": {"covered_lines": 7, "num_statements": 7, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 55}, "TestMain.test_main_runs_uvicorn": {"executed_lines": [72, 73, 75, 79, 81, 83, 84, 85, 86], "summary": {"covered_lines": 9, "num_statements": 9, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 70}, "": {"executed_lines": [3, 5, 6, 8, 9, 11, 12, 15, 16, 23, 24, 28, 35, 40, 54, 55, 69, 70], "summary": {"covered_lines": 18, "num_statements": 18, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 1}}, "classes": {"TestCreateApp": {"executed_lines": [25, 26, 29, 30, 31, 32, 33, 36, 37, 38, 42, 44, 45, 46, 48, 49, 50, 51], "summary": {"covered_lines": 18, "num_statements": 18, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 23}, "TestLifespan": {"executed_lines": [57, 58, 60, 62, 63, 64, 66], "summary": {"covered_lines": 7, "num_statements": 7, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 54}, "TestMain": {"executed_lines": [72, 73, 75, 79, 81, 83, 84, 85, 86], "summary": {"covered_lines": 9, "num_statements": 9, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 69}, "": {"executed_lines": [3, 5, 6, 8, 9, 11, 12, 15, 16, 17, 23, 24, 28, 35, 40, 54, 55, 69, 70], "summary": {"covered_lines": 19, "num_statements": 19, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 1}}}}, "totals": {"covered_lines": 242, "num_statements": 289, "percent_covered": 83.73702422145328, "percent_covered_display": "84", "missing_lines": 47, "excluded_lines": 0, "percent_statements_covered": 83.73702422145328, "percent_statements_covered_display": "84"}} \ No newline at end of file diff --git a/coverage-data/coverage.xml b/coverage-data/coverage.xml new file mode 100644 index 0000000..917febb --- /dev/null +++ b/coverage-data/coverage.xml @@ -0,0 +1,348 @@ + + + + + + /mnt/usb-data/projects/acme.api/acme_api + /mnt/usb-data/projects/acme.api/tests + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From fbc162d4e3a3a74382e9bdcb195ae3aa233e1f29 Mon Sep 17 00:00:00 2001 From: Streaky Date: Wed, 1 Jul 2026 17:49:40 +0100 Subject: [PATCH 06/26] remove coverage data --- coverage-data/coverage.json | 1 - coverage-data/coverage.xml | 348 ------------------------------------ 2 files changed, 349 deletions(-) delete mode 100644 coverage-data/coverage.json delete mode 100644 coverage-data/coverage.xml diff --git a/coverage-data/coverage.json b/coverage-data/coverage.json deleted file mode 100644 index e1934e1..0000000 --- a/coverage-data/coverage.json +++ /dev/null @@ -1 +0,0 @@ -{"meta": {"format": 3, "version": "7.14.3", "timestamp": "2026-07-01T14:54:27.767828", "branch_coverage": false, "show_contexts": false}, "files": {"acme_api/__init__.py": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 0, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "functions": {"": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 0, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 1}}, "classes": {"": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 0, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 1}}}, "acme_api/config.py": {"executed_lines": [8, 10, 11, 12, 14, 15, 18, 21, 22, 25, 28, 29, 32, 35, 36, 37, 40, 43, 44, 47, 50, 51, 54, 65, 66, 67, 68, 69, 72, 86, 87, 88, 89, 93, 94, 95, 96, 98], "summary": {"covered_lines": 38, "num_statements": 39, "percent_covered": 97.43589743589743, "percent_covered_display": "97", "missing_lines": 1, "excluded_lines": 0, "percent_statements_covered": 97.43589743589743, "percent_statements_covered_display": "97"}, "missing_lines": [91], "excluded_lines": [], "functions": {"load_config": {"executed_lines": [86, 87, 88, 89, 93, 94, 95, 96, 98], "summary": {"covered_lines": 9, "num_statements": 10, "percent_covered": 90.0, "percent_covered_display": "90", "missing_lines": 1, "excluded_lines": 0, "percent_statements_covered": 90.0, "percent_statements_covered_display": "90"}, "missing_lines": [91], "excluded_lines": [], "start_line": 72}, "": {"executed_lines": [8, 10, 11, 12, 14, 15, 18, 21, 22, 25, 28, 29, 32, 35, 36, 37, 40, 43, 44, 47, 50, 51, 54, 65, 66, 67, 68, 69, 72], "summary": {"covered_lines": 29, "num_statements": 29, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 1}}, "classes": {"LogConfig": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 0, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 18}, "DatabaseConfig": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 0, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 25}, "DeploymentConfig": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 0, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 32}, "AcmeConfig": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 0, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 40}, "RenewalConfig": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 0, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 47}, "AppSettings": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 0, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 54}, "": {"executed_lines": [8, 10, 11, 12, 14, 15, 18, 21, 22, 25, 28, 29, 32, 35, 36, 37, 40, 43, 44, 47, 50, 51, 54, 65, 66, 67, 68, 69, 72, 86, 87, 88, 89, 93, 94, 95, 96, 98], "summary": {"covered_lines": 38, "num_statements": 39, "percent_covered": 97.43589743589743, "percent_covered_display": "97", "missing_lines": 1, "excluded_lines": 0, "percent_statements_covered": 97.43589743589743, "percent_statements_covered_display": "97"}, "missing_lines": [91], "excluded_lines": [], "start_line": 1}}}, "acme_api/logging.py": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 32, "percent_covered": 0.0, "percent_covered_display": "0", "missing_lines": 32, "excluded_lines": 0, "percent_statements_covered": 0.0, "percent_statements_covered_display": "0"}, "missing_lines": [7, 8, 9, 10, 11, 12, 15, 18, 21, 23, 31, 32, 34, 35, 37, 40, 42, 44, 45, 47, 48, 50, 51, 53, 54, 55, 57, 59, 60, 61, 62, 63], "excluded_lines": [], "functions": {"JSONFormatter.format": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 6, "percent_covered": 0.0, "percent_covered_display": "0", "missing_lines": 6, "excluded_lines": 0, "percent_statements_covered": 0.0, "percent_statements_covered_display": "0"}, "missing_lines": [23, 31, 32, 34, 35, 37], "excluded_lines": [], "start_line": 21}, "setup_logging": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 10, "percent_covered": 0.0, "percent_covered_display": "0", "missing_lines": 10, "excluded_lines": 0, "percent_statements_covered": 0.0, "percent_statements_covered_display": "0"}, "missing_lines": [42, 44, 45, 47, 48, 50, 51, 53, 54, 55], "excluded_lines": [], "start_line": 40}, "get_request_id": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 5, "percent_covered": 0.0, "percent_covered_display": "0", "missing_lines": 5, "excluded_lines": 0, "percent_statements_covered": 0.0, "percent_statements_covered_display": "0"}, "missing_lines": [59, 60, 61, 62, 63], "excluded_lines": [], "start_line": 57}, "": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 11, "percent_covered": 0.0, "percent_covered_display": "0", "missing_lines": 11, "excluded_lines": 0, "percent_statements_covered": 0.0, "percent_statements_covered_display": "0"}, "missing_lines": [7, 8, 9, 10, 11, 12, 15, 18, 21, 40, 57], "excluded_lines": [], "start_line": 1}}, "classes": {"JSONFormatter": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 6, "percent_covered": 0.0, "percent_covered_display": "0", "missing_lines": 6, "excluded_lines": 0, "percent_statements_covered": 0.0, "percent_statements_covered_display": "0"}, "missing_lines": [23, 31, 32, 34, 35, 37], "excluded_lines": [], "start_line": 18}, "": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 26, "percent_covered": 0.0, "percent_covered_display": "0", "missing_lines": 26, "excluded_lines": 0, "percent_statements_covered": 0.0, "percent_statements_covered_display": "0"}, "missing_lines": [7, 8, 9, 10, 11, 12, 15, 18, 21, 40, 42, 44, 45, 47, 48, 50, 51, 53, 54, 55, 57, 59, 60, 61, 62, 63], "excluded_lines": [], "start_line": 1}}}, "acme_api/main.py": {"executed_lines": [7, 9, 10, 11, 13, 14, 15, 17, 20, 21, 26, 27, 28, 29, 30, 31, 34, 44, 47, 57, 59, 60, 62, 64, 65, 68, 69, 71, 74, 76, 77, 78, 80, 89], "summary": {"covered_lines": 34, "num_statements": 36, "percent_covered": 94.44444444444444, "percent_covered_display": "94", "missing_lines": 2, "excluded_lines": 0, "percent_statements_covered": 94.44444444444444, "percent_statements_covered_display": "94"}, "missing_lines": [45, 90], "excluded_lines": [], "functions": {"lifespan": {"executed_lines": [26, 27, 28, 29, 30, 31], "summary": {"covered_lines": 6, "num_statements": 6, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 21}, "create_app": {"executed_lines": [44, 47, 57, 59, 60, 64, 65, 71], "summary": {"covered_lines": 8, "num_statements": 9, "percent_covered": 88.88888888888889, "percent_covered_display": "89", "missing_lines": 1, "excluded_lines": 0, "percent_statements_covered": 88.88888888888889, "percent_statements_covered_display": "89"}, "missing_lines": [45], "excluded_lines": [], "start_line": 34}, "create_app.health": {"executed_lines": [62], "summary": {"covered_lines": 1, "num_statements": 1, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 60}, "create_app.unhandled_exception_handler": {"executed_lines": [68, 69], "summary": {"covered_lines": 2, "num_statements": 2, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 65}, "main": {"executed_lines": [76, 77, 78, 80], "summary": {"covered_lines": 4, "num_statements": 4, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 74}, "": {"executed_lines": [7, 9, 10, 11, 13, 14, 15, 17, 20, 21, 34, 74, 89], "summary": {"covered_lines": 13, "num_statements": 14, "percent_covered": 92.85714285714286, "percent_covered_display": "93", "missing_lines": 1, "excluded_lines": 0, "percent_statements_covered": 92.85714285714286, "percent_statements_covered_display": "93"}, "missing_lines": [90], "excluded_lines": [], "start_line": 1}}, "classes": {"": {"executed_lines": [7, 9, 10, 11, 13, 14, 15, 17, 20, 21, 26, 27, 28, 29, 30, 31, 34, 44, 47, 57, 59, 60, 62, 64, 65, 68, 69, 71, 74, 76, 77, 78, 80, 89], "summary": {"covered_lines": 34, "num_statements": 36, "percent_covered": 94.44444444444444, "percent_covered_display": "94", "missing_lines": 2, "excluded_lines": 0, "percent_statements_covered": 94.44444444444444, "percent_statements_covered_display": "94"}, "missing_lines": [45, 90], "excluded_lines": [], "start_line": 1}}}, "acme_api/middleware.py": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 11, "percent_covered": 0.0, "percent_covered_display": "0", "missing_lines": 11, "excluded_lines": 0, "percent_statements_covered": 0.0, "percent_statements_covered_display": "0"}, "missing_lines": [7, 8, 9, 12, 15, 16, 18, 22, 24, 25, 26], "excluded_lines": [], "functions": {"RequestIdMiddleware.__init__": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 1, "percent_covered": 0.0, "percent_covered_display": "0", "missing_lines": 1, "excluded_lines": 0, "percent_statements_covered": 0.0, "percent_statements_covered_display": "0"}, "missing_lines": [16], "excluded_lines": [], "start_line": 15}, "RequestIdMiddleware.__call__": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 4, "percent_covered": 0.0, "percent_covered_display": "0", "missing_lines": 4, "excluded_lines": 0, "percent_statements_covered": 0.0, "percent_statements_covered_display": "0"}, "missing_lines": [22, 24, 25, 26], "excluded_lines": [], "start_line": 18}, "": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 6, "percent_covered": 0.0, "percent_covered_display": "0", "missing_lines": 6, "excluded_lines": 0, "percent_statements_covered": 0.0, "percent_statements_covered_display": "0"}, "missing_lines": [7, 8, 9, 12, 15, 18], "excluded_lines": [], "start_line": 1}}, "classes": {"RequestIdMiddleware": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 5, "percent_covered": 0.0, "percent_covered_display": "0", "missing_lines": 5, "excluded_lines": 0, "percent_statements_covered": 0.0, "percent_statements_covered_display": "0"}, "missing_lines": [16, 22, 24, 25, 26], "excluded_lines": [], "start_line": 12}, "": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 6, "percent_covered": 0.0, "percent_covered_display": "0", "missing_lines": 6, "excluded_lines": 0, "percent_statements_covered": 0.0, "percent_statements_covered_display": "0"}, "missing_lines": [7, 8, 9, 12, 15, 18], "excluded_lines": [], "start_line": 1}}}, "tests/conftest.py": {"executed_lines": [3, 5, 6, 7, 9, 10, 11, 14, 15, 19, 20, 22, 23, 25, 26, 27, 28, 30, 34, 37, 38, 40, 41, 42], "summary": {"covered_lines": 24, "num_statements": 25, "percent_covered": 96.0, "percent_covered_display": "96", "missing_lines": 1, "excluded_lines": 0, "percent_statements_covered": 96.0, "percent_statements_covered_display": "96"}, "missing_lines": [16], "excluded_lines": [], "functions": {"app": {"executed_lines": [22, 23, 25, 26, 27, 28, 30, 34], "summary": {"covered_lines": 8, "num_statements": 8, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 20}, "client": {"executed_lines": [40, 41, 42], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 38}, "": {"executed_lines": [3, 5, 6, 7, 9, 10, 11, 14, 15, 19, 20, 37, 38], "summary": {"covered_lines": 13, "num_statements": 14, "percent_covered": 92.85714285714286, "percent_covered_display": "93", "missing_lines": 1, "excluded_lines": 0, "percent_statements_covered": 92.85714285714286, "percent_statements_covered_display": "93"}, "missing_lines": [16], "excluded_lines": [], "start_line": 1}}, "classes": {"": {"executed_lines": [3, 5, 6, 7, 9, 10, 11, 14, 15, 19, 20, 22, 23, 25, 26, 27, 28, 30, 34, 37, 38, 40, 41, 42], "summary": {"covered_lines": 24, "num_statements": 25, "percent_covered": 96.0, "percent_covered_display": "96", "missing_lines": 1, "excluded_lines": 0, "percent_statements_covered": 96.0, "percent_statements_covered_display": "96"}, "missing_lines": [16], "excluded_lines": [], "start_line": 1}}}, "tests/test_config.py": {"executed_lines": [3, 5, 7, 8, 10, 21, 22, 23, 24, 25, 27, 28, 29, 30, 33, 34, 35, 36, 37, 40, 41, 42, 43, 45, 46, 47, 50, 51, 52, 53, 54, 57, 58, 59, 60, 62, 63, 64, 65, 68, 69, 70, 71, 72, 73, 75, 76, 77, 79, 82, 83, 85, 86, 88, 89, 90, 94, 95, 97, 98, 99, 101, 102, 103, 104, 106, 107, 108, 110, 112, 113, 114, 115, 116, 118, 119, 120, 121, 123, 124, 126, 127, 128, 129, 132, 133, 134], "summary": {"covered_lines": 87, "num_statements": 87, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "functions": {"TestLogConfig.test_defaults": {"executed_lines": [23, 24, 25], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 22}, "TestLogConfig.test_custom_level": {"executed_lines": [28, 29, 30], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 27}, "TestDatabaseConfig.test_defaults": {"executed_lines": [35, 36, 37], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 34}, "TestDeploymentConfig.test_defaults": {"executed_lines": [42, 43], "summary": {"covered_lines": 2, "num_statements": 2, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 41}, "TestDeploymentConfig.test_custom_directory": {"executed_lines": [46, 47], "summary": {"covered_lines": 2, "num_statements": 2, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 45}, "TestAcmeConfig.test_defaults": {"executed_lines": [52, 53, 54], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 51}, "TestRenewalConfig.test_defaults": {"executed_lines": [59, 60], "summary": {"covered_lines": 2, "num_statements": 2, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 58}, "TestRenewalConfig.test_custom_window": {"executed_lines": [63, 64, 65], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 62}, "TestAppSettings.test_full_defaults": {"executed_lines": [70, 71, 72, 73], "summary": {"covered_lines": 4, "num_statements": 4, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 69}, "TestAppSettings.test_partial_override": {"executed_lines": [76, 77, 79], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 75}, "TestLoadConfig.test_missing_file_returns_defaults": {"executed_lines": [85, 86], "summary": {"covered_lines": 2, "num_statements": 2, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 83}, "TestLoadConfig.test_load_valid_yaml": {"executed_lines": [89, 90, 94, 95, 97, 98, 99], "summary": {"covered_lines": 7, "num_statements": 7, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 88}, "TestLoadConfig.test_env_var_path": {"executed_lines": [102, 103, 104, 106, 107, 108], "summary": {"covered_lines": 6, "num_statements": 6, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 101}, "TestLoadConfig.test_empty_yaml": {"executed_lines": [112, 113, 114, 115, 116], "summary": {"covered_lines": 5, "num_statements": 5, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 110}, "TestLoadConfig.test_invalid_log_level": {"executed_lines": [119, 120, 121, 123, 124], "summary": {"covered_lines": 5, "num_statements": 5, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 118}, "TestLoadConfig.test_fallback_to_cwd": {"executed_lines": [127, 128, 129, 132, 133, 134], "summary": {"covered_lines": 6, "num_statements": 6, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 126}, "": {"executed_lines": [3, 5, 7, 8, 10, 21, 22, 27, 33, 34, 40, 41, 45, 50, 51, 57, 58, 62, 68, 69, 75, 82, 83, 88, 101, 110, 118, 126], "summary": {"covered_lines": 28, "num_statements": 28, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 1}}, "classes": {"TestLogConfig": {"executed_lines": [23, 24, 25, 28, 29, 30], "summary": {"covered_lines": 6, "num_statements": 6, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 21}, "TestDatabaseConfig": {"executed_lines": [35, 36, 37], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 33}, "TestDeploymentConfig": {"executed_lines": [42, 43, 46, 47], "summary": {"covered_lines": 4, "num_statements": 4, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 40}, "TestAcmeConfig": {"executed_lines": [52, 53, 54], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 50}, "TestRenewalConfig": {"executed_lines": [59, 60, 63, 64, 65], "summary": {"covered_lines": 5, "num_statements": 5, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 57}, "TestAppSettings": {"executed_lines": [70, 71, 72, 73, 76, 77, 79], "summary": {"covered_lines": 7, "num_statements": 7, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 68}, "TestLoadConfig": {"executed_lines": [85, 86, 89, 90, 94, 95, 97, 98, 99, 102, 103, 104, 106, 107, 108, 112, 113, 114, 115, 116, 119, 120, 121, 123, 124, 127, 128, 129, 132, 133, 134], "summary": {"covered_lines": 31, "num_statements": 31, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 82}, "": {"executed_lines": [3, 5, 7, 8, 10, 21, 22, 27, 33, 34, 40, 41, 45, 50, 51, 57, 58, 62, 68, 69, 75, 82, 83, 88, 101, 110, 118, 126], "summary": {"covered_lines": 28, "num_statements": 28, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 1}}}, "tests/test_health.py": {"executed_lines": [3, 5, 8, 11, 12, 13], "summary": {"covered_lines": 6, "num_statements": 6, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "functions": {"test_health_returns_ok": {"executed_lines": [11, 12, 13], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 8}, "": {"executed_lines": [3, 5, 8], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 1}}, "classes": {"": {"executed_lines": [3, 5, 8, 11, 12, 13], "summary": {"covered_lines": 6, "num_statements": 6, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 1}}}, "tests/test_main.py": {"executed_lines": [3, 5, 6, 8, 9, 11, 12, 15, 16, 17, 23, 24, 25, 26, 28, 29, 30, 31, 32, 33, 35, 36, 37, 38, 40, 42, 44, 45, 46, 48, 49, 50, 51, 54, 55, 57, 58, 60, 62, 63, 64, 66, 69, 70, 72, 73, 75, 79, 81, 83, 84, 85, 86], "summary": {"covered_lines": 53, "num_statements": 53, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "functions": {"settings": {"executed_lines": [17], "summary": {"covered_lines": 1, "num_statements": 1, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 16}, "TestCreateApp.test_returns_fastapi_instance": {"executed_lines": [25, 26], "summary": {"covered_lines": 2, "num_statements": 2, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 24}, "TestCreateApp.test_health_endpoint_ok": {"executed_lines": [29, 30, 31, 32, 33], "summary": {"covered_lines": 5, "num_statements": 5, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 28}, "TestCreateApp.test_settings_stored_on_state": {"executed_lines": [36, 37, 38], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 35}, "TestCreateApp.test_unhandled_exception_returns_500": {"executed_lines": [42, 44, 45, 48, 49, 50, 51], "summary": {"covered_lines": 7, "num_statements": 7, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 40}, "TestCreateApp.test_unhandled_exception_returns_500.boom_route": {"executed_lines": [46], "summary": {"covered_lines": 1, "num_statements": 1, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 45}, "TestLifespan.test_lifespan_yields": {"executed_lines": [57, 58, 60, 62, 63, 64, 66], "summary": {"covered_lines": 7, "num_statements": 7, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 55}, "TestMain.test_main_runs_uvicorn": {"executed_lines": [72, 73, 75, 79, 81, 83, 84, 85, 86], "summary": {"covered_lines": 9, "num_statements": 9, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 70}, "": {"executed_lines": [3, 5, 6, 8, 9, 11, 12, 15, 16, 23, 24, 28, 35, 40, 54, 55, 69, 70], "summary": {"covered_lines": 18, "num_statements": 18, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 1}}, "classes": {"TestCreateApp": {"executed_lines": [25, 26, 29, 30, 31, 32, 33, 36, 37, 38, 42, 44, 45, 46, 48, 49, 50, 51], "summary": {"covered_lines": 18, "num_statements": 18, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 23}, "TestLifespan": {"executed_lines": [57, 58, 60, 62, 63, 64, 66], "summary": {"covered_lines": 7, "num_statements": 7, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 54}, "TestMain": {"executed_lines": [72, 73, 75, 79, 81, 83, 84, 85, 86], "summary": {"covered_lines": 9, "num_statements": 9, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 69}, "": {"executed_lines": [3, 5, 6, 8, 9, 11, 12, 15, 16, 17, 23, 24, 28, 35, 40, 54, 55, 69, 70], "summary": {"covered_lines": 19, "num_statements": 19, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0, "percent_statements_covered": 100.0, "percent_statements_covered_display": "100"}, "missing_lines": [], "excluded_lines": [], "start_line": 1}}}}, "totals": {"covered_lines": 242, "num_statements": 289, "percent_covered": 83.73702422145328, "percent_covered_display": "84", "missing_lines": 47, "excluded_lines": 0, "percent_statements_covered": 83.73702422145328, "percent_statements_covered_display": "84"}} \ No newline at end of file diff --git a/coverage-data/coverage.xml b/coverage-data/coverage.xml deleted file mode 100644 index 917febb..0000000 --- a/coverage-data/coverage.xml +++ /dev/null @@ -1,348 +0,0 @@ - - - - - - /mnt/usb-data/projects/acme.api/acme_api - /mnt/usb-data/projects/acme.api/tests - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - From 9bc945001844cc77d8f1328b460a789f4981923c Mon Sep 17 00:00:00 2001 From: Streaky Date: Wed, 1 Jul 2026 17:50:14 +0100 Subject: [PATCH 07/26] phase 1 checkpoint --- acme_api/config.py | 11 ++- acme_api/logging.py | 63 ++++++++++++++++ acme_api/main.py | 3 +- acme_api/middleware.py | 60 ++++++++++++++++ config.example.yaml | 37 ++++++++++ pyproject.toml | 3 +- requirements-dev.txt | 15 +++- requirements.txt | 20 +++--- tests/test_config.py | 107 +++++++++++++++++++++++++++ tests/test_logging.py | 151 +++++++++++++++++++++++++++++++++++++++ tests/test_main.py | 8 ++- tests/test_middleware.py | 137 +++++++++++++++++++++++++++++++++++ 12 files changed, 598 insertions(+), 17 deletions(-) create mode 100644 acme_api/logging.py create mode 100644 acme_api/middleware.py create mode 100644 config.example.yaml create mode 100644 tests/test_logging.py create mode 100644 tests/test_middleware.py diff --git a/acme_api/config.py b/acme_api/config.py index a090b58..b8cf074 100644 --- a/acme_api/config.py +++ b/acme_api/config.py @@ -70,12 +70,20 @@ class AcmeAccountConfig(BaseModel): account_key_path: Path | None = None +class AcmeConfig(BaseModel): + """acme.sh binary and state directory configuration.""" + + binary_path: str = "/usr/local/bin/acme.sh" + home_dir: Path = Path("/acmesh") + + class RenewalConfig(BaseModel): """Automatic renewal scheduling configuration.""" window_days: int = Field(default=30, ge=1) max_retries: int = Field(default=3, ge=0) + class AppSettings(BaseModel): """Top-level application settings loaded from config.yaml. @@ -96,7 +104,7 @@ class AppSettings(BaseModel): dns_providers: list[DnsProviderConfig] = Field(default_factory=list) acme_accounts: list[AcmeAccountConfig] = Field(default_factory=list) - def validate(self) -> None: + def check(self) -> None: """Validate configuration at startup. Performs runtime checks that cannot be expressed with Pydantic field @@ -156,6 +164,7 @@ def validate(self) -> None: if errors: raise ValueError("\n".join(errors)) + def load_config(path: Path | None = None) -> AppSettings: """Load and validate configuration from a YAML file. diff --git a/acme_api/logging.py b/acme_api/logging.py new file mode 100644 index 0000000..4e7f904 --- /dev/null +++ b/acme_api/logging.py @@ -0,0 +1,63 @@ +"""Structured logging configuration for acme.api. + +Provides JSON formatting for production logs and standard text for development, +including request ID context propagation via contextvars. +""" + +import json +import logging +from contextvars import ContextVar +from typing import Any +from uuid import uuid4 + +# Thread-safe storage for the current request's correlation ID +request_id: ContextVar[str | None] = ContextVar("request_id", default=None) + + +class JSONFormatter(logging.Formatter): + """A logging formatter that outputs in JSON format.""" + + def format(self, record: logging.LogRecord) -> str: + # Capture context-aware data + log_data: dict[str, Any] = { + "timestamp": self.formatTime(record), + "level": record.levelname, + "message": record.getMessage(), + "logger": record.name, + "request_id": request_id.get(), + } + + if hasattr(record, "extra"): + log_data.update(record.extra) + + if record.exc_info: + log_data["exception"] = self.formatException(record.exc_info) + + return json.dumps(log_data) + + +def setup_logging(level: str, format_type: str = "json") -> None: + """Configures the root logger.""" + handler = logging.StreamHandler() + + if format_type == "json": + handler.setFormatter(JSONFormatter()) + else: + formatter = logging.Formatter("%(asctime)s %(levelname)s [%(name)s] %(message)s") + handler.setFormatter(formatter) + + logger = logging.getLogger() + logger.setLevel(getattr(logging, level.upper(), logging.INFO)) + # Clear existing handlers to avoid duplication if called twice (e.g. in tests) + for h in logger.handlers[:]: + logger.removeHandler(h) + logger.addHandler(handler) + + +def get_request_id() -> str: + """Returns the current request ID or generates a new one.""" + rid = request_id.get() + if rid is None: + rid = str(uuid4()) + request_id.set(rid) + return rid diff --git a/acme_api/main.py b/acme_api/main.py index 9d7ee5b..8c1d85c 100644 --- a/acme_api/main.py +++ b/acme_api/main.py @@ -13,7 +13,6 @@ import uvicorn from fastapi import FastAPI, Request, Response from fastapi.responses import JSONResponse -from starlette.middleware.base import BaseHTTPMiddleware from acme_api.config import AppSettings, load_config from acme_api.logging import setup_logging @@ -28,7 +27,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]: """ settings = app.state.settings # Run config validation before anything else - settings.validate() + settings.check() # Configure structured logging according to settings setup_logging(level=settings.log.level, format_type=settings.log.format) root_logger = logging.getLogger(__name__) diff --git a/acme_api/middleware.py b/acme_api/middleware.py new file mode 100644 index 0000000..08b67f9 --- /dev/null +++ b/acme_api/middleware.py @@ -0,0 +1,60 @@ +"""Request ID middleware for FastAPI. + +Injects a unique request ID into each HTTP request's context, +which is then logged by the structured JSON formatter. +""" + +from __future__ import annotations + +from typing import Any, Callable, cast +from uuid import uuid4 + +from acme_api.logging import request_id as _request_id_ctxvar + +ASGIApp = Callable[[Any, Any, Any], Any] + + +class RequestIdMiddleware: + """Middleware to add request IDs to all requests. + + Sets a unique correlation ID per-request in both ``request.state`` and the + logging context variable so structured logs can be traced back to the + originating HTTP request. + """ + + def __init__(self, app: ASGIApp) -> None: + self.app = app + + async def __call__( + self, + scope: dict[str, Any], + receive: Callable[..., Any], + send: Callable[[dict[str, Any]], Any], + ) -> None: # noqa: ANN401 + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + rid = str(uuid4()) + token = _request_id_ctxvar.set(rid) + + # Wrap send to inject the X-Request-ID response header + headers_sent = False + + async def wrapped_send(message: dict[str, Any]) -> None: + nonlocal headers_sent + if message["type"] == "http.response.start" and not headers_sent: + hdrs = cast(list[tuple[bytes, bytes]], message.get("headers", [])) + for i, (key, _) in enumerate(hdrs): + if key == b"x-request-id": + hdrs[i] = (key, rid.encode()) + break + else: + hdrs.append((b"x-request-id", rid.encode())) + headers_sent = True + await send(message) + + try: + await self.app(scope, receive, wrapped_send) + finally: + _request_id_ctxvar.reset(token) diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..013adda --- /dev/null +++ b/config.example.yaml @@ -0,0 +1,37 @@ +# acme.api — Example Configuration +# Copy this file to `config.yaml` and adjust values for your environment. +# Path: set ACME_API_CONFIG=/path/to/config.yaml (or leave unset; cwd is searched) + +log: + level: INFO # DEBUG, INFO, WARNING, ERROR, CRITICAL + format: json # json or text + +database: + url: sqlite+aiosqlite:///data/acme.db + pool_size: 5 + +deployment: + directory: /certificates + permissions_key: 384 # 0o600 in decimal + +acme: + binary_path: /usr/local/bin/acme.sh + home_dir: /acmesh + +renewal: + enabled: true + check_interval_hours: 24 + renewal_window_days: 30 + max_retries: 3 + +# DNS provider aliases referenced by name in the API. +# Each provider must have a credentials file with acme.sh-compatible env vars. +dns_providers: + - name: production + provider_name: cloudflare + env_vars_file_path: /secrets/cloudflare.env + +acme_accounts: + - name: letsencrypt-production + server_url: https://acme-v02.api.letsencrypt.org/directory + # account_key_path is optional; defaults to a path inside acme.home_dir diff --git a/pyproject.toml b/pyproject.toml index 4225128..f088796 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ dependencies = [ "aiosqlite", "apscheduler", "pydantic-settings", - "httpx", + "httpx2", "prometheus-client", "passlib[bcrypt]", "aiofiles", @@ -79,6 +79,7 @@ disable = [ "too-few-public-methods", "no-member", "unspecified-encoding", + "duplicate-code", ] [tool.flake8] diff --git a/requirements-dev.txt b/requirements-dev.txt index a1107c8..3c67d9c 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -24,6 +24,7 @@ anyio==4.14.1 # via # -c requirements.txt # httpx + # httpx2 # starlette # watchfiles apscheduler==3.11.3 @@ -42,7 +43,6 @@ black==26.5.1 # via acme.api (pyproject.toml) certifi==2026.6.17 # via - # -c requirements.txt # httpcore # httpx click==8.4.2 @@ -68,16 +68,21 @@ h11==0.16.0 # via # -c requirements.txt # httpcore + # httpcore2 # uvicorn httpcore==1.0.9 + # via httpx +httpcore2==2.5.0 # via # -c requirements.txt - # httpx + # httpx2 httptools==0.8.0 # via # -c requirements.txt # uvicorn httpx==0.28.1 + # via acme.api (pyproject.toml) +httpx2==2.5.0 # via # -c requirements.txt # acme.api (pyproject.toml) @@ -86,6 +91,7 @@ idna==3.18 # -c requirements.txt # anyio # httpx + # httpx2 iniconfig==2.3.0 # via pytest isort==8.0.1 @@ -180,6 +186,11 @@ starlette==1.3.1 # fastapi tomlkit==0.15.0 # via pylint +truststore==0.10.4 + # via + # -c requirements.txt + # httpcore2 + # httpx2 types-pyyaml==6.0.12.20260518 # via acme.api (pyproject.toml) typing-extensions==4.15.0 diff --git a/requirements.txt b/requirements.txt index a7d886a..52a5ea7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,17 +14,13 @@ annotated-types==0.7.0 # via pydantic anyio==4.14.1 # via - # httpx + # httpx2 # starlette # watchfiles apscheduler==3.11.3 # via acme.api (pyproject.toml) bcrypt==5.0.0 # via passlib -certifi==2026.6.17 - # via - # httpcore - # httpx click==8.4.2 # via uvicorn fastapi==0.138.2 @@ -33,18 +29,18 @@ greenlet==3.5.3 # via sqlalchemy h11==0.16.0 # via - # httpcore + # httpcore2 # uvicorn -httpcore==1.0.9 - # via httpx +httpcore2==2.5.0 + # via httpx2 httptools==0.8.0 # via uvicorn -httpx==0.28.1 +httpx2==2.5.0 # via acme.api (pyproject.toml) idna==3.18 # via # anyio - # httpx + # httpx2 passlib==1.7.4 # via acme.api (pyproject.toml) prometheus-client==0.25.0 @@ -69,6 +65,10 @@ sqlalchemy==2.0.51 # via acme.api (pyproject.toml) starlette==1.3.1 # via fastapi +truststore==0.10.4 + # via + # httpcore2 + # httpx2 typing-extensions==4.15.0 # via # fastapi diff --git a/tests/test_config.py b/tests/test_config.py index 3596860..bcf5cd7 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -8,10 +8,12 @@ import yaml from acme_api.config import ( + AcmeAccountConfig, AcmeConfig, AppSettings, DatabaseConfig, DeploymentConfig, + DnsProviderConfig, LogConfig, RenewalConfig, load_config, @@ -65,6 +67,44 @@ def test_custom_window(self) -> None: assert cfg.max_retries == 5 +class TestDnsProviderConfig: + def test_creation(self) -> None: + cfg = DnsProviderConfig( + name="production", + provider_name="cloudflare", + env_vars_file_path=Path("/data/creds/cloudflare.env"), + ) + assert cfg.name == "production" + assert cfg.provider_name == "cloudflare" + + def test_empty_name_fails(self) -> None: + with pytest.raises(ValueError): + DnsProviderConfig( + name="", + provider_name="cloudflare", + env_vars_file_path=Path("/data/env"), + ) + + +class TestAcmeAccountConfig: + def test_defaults(self) -> None: + cfg = AcmeAccountConfig(name="le-prod") + assert cfg.name == "le-prod" + assert cfg.server_url == "https://acme-v02.api.letsencrypt.org/directory" + assert cfg.account_key_path is None + + def test_custom_server(self) -> None: + cfg = AcmeAccountConfig( + name="le-staging", + server_url="https://acme-staging-v02.api.letsencrypt.org/directory", + ) + assert cfg.server_url == "https://acme-staging-v02.api.letsencrypt.org/directory" + + def test_empty_name_fails(self) -> None: + with pytest.raises(ValueError): + AcmeAccountConfig(name="") + + class TestAppSettings: def test_full_defaults(self) -> None: settings = AppSettings() @@ -132,3 +172,70 @@ def test_fallback_to_cwd(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) monkeypatch.delenv("ACME_API_CONFIG", raising=False) cfg = load_config(path=config_file) assert cfg.renewal.window_days == 15 + + +class TestAppSettingsValidate: + def test_validate_passes_with_tmp_dirs(self, tmp_path: Path) -> None: + settings = AppSettings( + database=DatabaseConfig(url=f"sqlite+aiosqlite:///{tmp_path}/test.db"), + deployment=DeploymentConfig(directory=tmp_path / "certs"), + acme=AcmeConfig(home_dir=tmp_path / "acmesh"), + ) + # Should not raise + settings.check() + + def test_validate_duplicate_dns_provider_names(self, tmp_path: Path) -> None: + settings = AppSettings( + database=DatabaseConfig(url=f"sqlite+aiosqlite:///{tmp_path}/test.db"), + deployment=DeploymentConfig(directory=tmp_path / "certs"), + acme=AcmeConfig(home_dir=tmp_path / "acmesh"), + dns_providers=[ + DnsProviderConfig( + name="dup", provider_name="cf", + env_vars_file_path=tmp_path / "a.env", + ), + DnsProviderConfig( + name="dup", provider_name="route53", + env_vars_file_path=tmp_path / "b.env", + ), + ], + ) + with pytest.raises(ValueError, match="duplicate"): + settings.check() + + def test_validate_duplicate_acme_account_names(self, tmp_path: Path) -> None: + settings = AppSettings( + database=DatabaseConfig(url=f"sqlite+aiosqlite:///{tmp_path}/test.db"), + deployment=DeploymentConfig(directory=tmp_path / "certs"), + acme=AcmeConfig(home_dir=tmp_path / "acmesh"), + acme_accounts=[ + AcmeAccountConfig(name="dup", server_url="https://a.com"), + AcmeAccountConfig(name="dup", server_url="https://b.com"), + ], + ) + with pytest.raises(ValueError, match="duplicate"): + settings.check() + + def test_validate_bad_database_url(self, tmp_path: Path) -> None: + settings = AppSettings( + database=DatabaseConfig(url="mysql://localhost/db"), + deployment=DeploymentConfig(directory=tmp_path / "certs"), + acme=AcmeConfig(home_dir=tmp_path / "acmesh"), + ) + with pytest.raises(ValueError, match="database.url"): + settings.check() + + def test_validate_missing_env_vars_file(self, tmp_path: Path) -> None: + settings = AppSettings( + database=DatabaseConfig(url=f"sqlite+aiosqlite:///{tmp_path}/test.db"), + deployment=DeploymentConfig(directory=tmp_path / "certs"), + acme=AcmeConfig(home_dir=tmp_path / "acmesh"), + dns_providers=[ + DnsProviderConfig( + name="prod", provider_name="cf", + env_vars_file_path=tmp_path / "missing.env", + ), + ], + ) + with pytest.raises(ValueError, match="env_vars_file_path"): + settings.check() diff --git a/tests/test_logging.py b/tests/test_logging.py new file mode 100644 index 0000000..a0ace06 --- /dev/null +++ b/tests/test_logging.py @@ -0,0 +1,151 @@ +"""Tests for structured logging configuration.""" + +from __future__ import annotations + +import json +import logging +import sys +from typing import Generator + +import pytest + +from acme_api.logging import ( + JSONFormatter, + get_request_id, +) +from acme_api.logging import request_id as _request_id_ctxvar +from acme_api.logging import ( + setup_logging, +) + + +class TestJSONFormatter: + def test_formats_basic_record(self) -> None: + fmt = JSONFormatter() + record = logging.LogRecord( + name="test", level=logging.INFO, pathname="", lineno=1, + msg="hello world", args=(), exc_info=None, + ) + output = fmt.format(record) + data = json.loads(output) + assert data["message"] == "hello world" + assert data["level"] == "INFO" + assert data["logger"] == "test" + + def test_includes_request_id(self) -> None: + token = _request_id_ctxvar.set("abc-123") + try: + fmt = JSONFormatter() + record = logging.LogRecord( + name="test", level=logging.INFO, pathname="", lineno=1, + msg="hello", args=(), exc_info=None, + ) + data = json.loads(fmt.format(record)) + assert data["request_id"] == "abc-123" + finally: + _request_id_ctxvar.reset(token) + + def test_null_request_id_when_unset(self) -> None: + token = _request_id_ctxvar.set(None) + try: + fmt = JSONFormatter() + record = logging.LogRecord( + name="test", level=logging.INFO, pathname="", lineno=1, + msg="hello", args=(), exc_info=None, + ) + data = json.loads(fmt.format(record)) + assert data["request_id"] is None + finally: + _request_id_ctxvar.reset(token) + + def test_includes_extra_data(self) -> None: + fmt = JSONFormatter() + record = logging.LogRecord( + name="test", level=logging.INFO, pathname="", lineno=1, + msg="hello", args=(), exc_info=None, + ) + record.extra = {"user": "admin", "action": "login"} # noqa: B027 + data = json.loads(fmt.format(record)) + assert data["user"] == "admin" + assert data["action"] == "login" + + def test_includes_exception_info(self) -> None: + fmt = JSONFormatter() + exc = ValueError("oops") + record = logging.LogRecord( + name="test", level=logging.ERROR, pathname="", lineno=1, + msg="error happened", args=(), + exc_info=(exc.__class__, exc, None), + ) + + data = json.loads(fmt.format(record)) + assert "exception" in data + assert "ValueError: oops" in data["exception"] + + +class TestSetupLogging: + @pytest.fixture(autouse=True) + def _reset_root_logger(self) -> Generator[None]: + """Reset the root logger's handlers after each test.""" + yield + logger = logging.getLogger() + for h in logger.handlers[:]: + logger.removeHandler(h) + + def test_json_format_sets_json_formatter(self) -> None: + setup_logging(level="INFO", format_type="json") + logger = logging.getLogger() + assert len(logger.handlers) == 1 + handler = logger.handlers[0] + assert isinstance(handler.formatter, JSONFormatter) + + def test_text_format_sets_standard_formatter(self) -> None: + setup_logging(level="DEBUG", format_type="text") + logger = logging.getLogger() + assert len(logger.handlers) == 1 + handler = logger.handlers[0] + assert not isinstance(handler.formatter, JSONFormatter) + + def test_sets_log_level_correctly(self) -> None: + setup_logging(level="DEBUG", format_type="text") + logger = logging.getLogger() + assert logger.level == logging.DEBUG + + def test_clears_existing_handlers(self) -> None: + logger = logging.getLogger() + dummy = logging.StreamHandler(sys.stdout) + logger.addHandler(dummy) + setup_logging(level="INFO", format_type="json") + assert dummy not in logger.handlers + + +class TestGetRequestId: + @pytest.fixture(autouse=True) + def _reset_ctxvar(self) -> Generator[None]: + token = _request_id_ctxvar.set(None) + yield + try: + _request_id_ctxvar.reset(token) + except ValueError: + pass + + def test_generates_new_id_when_unset(self) -> None: + rid1 = get_request_id() + assert isinstance(rid1, str) and len(rid1) > 0 + + def test_returns_existing_id_when_set(self) -> None: + token = _request_id_ctxvar.set("existing-id") + try: + rid = get_request_id() + assert rid == "existing-id" + finally: + _request_id_ctxvar.reset(token) + + def test_different_calls_return_same_id_within_context(self) -> None: + token = _request_id_ctxvar.set("contextual-id") + try: + rid1 = get_request_id() + rid2 = get_request_id() + assert rid1 == rid2 == "contextual-id" + finally: + _request_id_ctxvar.reset(token) diff --git a/tests/test_main.py b/tests/test_main.py index 29a752b..9d88172 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -8,7 +8,12 @@ import pytest from fastapi.testclient import TestClient -from acme_api.config import AppSettings, DatabaseConfig, DeploymentConfig +from acme_api.config import ( + AcmeConfig, + AppSettings, + DatabaseConfig, + DeploymentConfig, +) from acme_api.main import create_app @@ -17,6 +22,7 @@ def settings(tmp_path: Path) -> AppSettings: return AppSettings( database=DatabaseConfig(url=f"sqlite+aiosqlite:///{tmp_path}/test.db"), deployment=DeploymentConfig(directory=tmp_path / "certs"), + acme=AcmeConfig(home_dir=tmp_path / "acmesh"), ) diff --git a/tests/test_middleware.py b/tests/test_middleware.py new file mode 100644 index 0000000..2febf92 --- /dev/null +++ b/tests/test_middleware.py @@ -0,0 +1,137 @@ +"""Tests for request ID middleware.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any, Callable, Generator + +import pytest +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient + +from acme_api.config import AppSettings, DatabaseConfig, DeploymentConfig +from acme_api.logging import request_id as _request_id_ctxvar + + +@pytest.fixture(autouse=True) +def _reset_ctxvar() -> Generator[None]: + token = _request_id_ctxvar.set(None) + yield + try: + _request_id_ctxvar.reset(token) + except ValueError: + pass + + +async def _make_app(tmp_path: Path) -> FastAPI: + from acme_api.main import create_app + + settings = AppSettings( + database=DatabaseConfig(url=f"sqlite+aiosqlite:///{tmp_path}/test.db"), + deployment=DeploymentConfig(directory=tmp_path / "certs"), + ) + return create_app(settings=settings) + + +@pytest.mark.anyio +async def test_middleware_injects_request_id_header(tmp_path: Path) -> None: + """Middleware adds X-Request-ID header to responses.""" + app = await _make_app(tmp_path) + transport = ASGITransport(app=app) + + async with AsyncClient(transport=transport, base_url="http://test") as client: + resp = await client.get("/health") + + assert "x-request-id" in resp.headers + rid = resp.headers["x-request-id"] + assert len(rid) > 0 + + +@pytest.mark.anyio +async def test_middleware_different_id_per_request(tmp_path: Path) -> None: + """Each request gets a unique X-Request-ID.""" + app = await _make_app(tmp_path) + transport = ASGITransport(app=app) + + async with AsyncClient(transport=transport, base_url="http://test") as client: + r1 = await client.get("/health") + r2 = await client.get("/health") + + h1 = r1.headers["x-request-id"] + h2 = r2.headers["x-request-id"] + assert h1 != h2 + + +@pytest.mark.anyio +async def test_middleware_resets_context_variable(tmp_path: Path) -> None: + """Context variable is reset to None after the request completes.""" + app = await _make_app(tmp_path) + transport = ASGITransport(app=app) + + assert _request_id_ctxvar.get() is None + + async with AsyncClient(transport=transport, base_url="http://test") as client: + await client.get("/health") + + # After the request finishes, the context should be reset + assert _request_id_ctxvar.get() is None + + +@pytest.mark.anyio +async def test_middleware_resets_context_on_exception() -> None: + """Context variable is reset even when the handler raises.""" + from acme_api.middleware import RequestIdMiddleware + + async def _app( + scope: dict[str, Any], receive: Callable[..., Any], send: Callable[[dict[str, Any]], Any] + ) -> None: # noqa: ANN401 + raise RuntimeError("boom") + + middleware = RequestIdMiddleware(app=_app) + + scope = {"type": "http", "path": "/fail"} + + async def _receive() -> dict[str, Any]: + return {} + + def _send(_message: dict[str, Any]) -> None: + pass + + with pytest.raises(RuntimeError, match="boom"): + await middleware(scope, lambda _: None, lambda x: None) + + assert _request_id_ctxvar.get() is None + + +@pytest.mark.anyio +async def test_middleware_skips_non_http_scope() -> None: + """Middleware passes through non-HTTP scopes without setting request ID.""" + from acme_api.middleware import RequestIdMiddleware + + messages = [] + + async def _send(message: dict[str, Any]) -> None: + messages.append(message) + + middleware = RequestIdMiddleware( # noqa: B035 — intentional lambda for test + app=lambda s, r, sd: _send(sd), # noqa: B026 + ) + + scope = {"type": "websocket", "path": "/ws"} + await middleware(scope, lambda _: None, _send) + + assert len(messages) == 1 + + +@pytest.mark.anyio +async def test_middleware_preserves_app() -> None: + """Middleware stores a reference to the underlying app.""" + from acme_api.middleware import RequestIdMiddleware + + async def _dummy( + _scope: dict[str, Any], _receive: Callable[..., Any], _send: Callable[[dict[str, Any]], Any] + ) -> None: # noqa: ANN401 + pass + + middleware = RequestIdMiddleware(app=_dummy) + assert callable(middleware.app) From 52630eae91d5a4b7ae02ba0dd71e9b15c666234d Mon Sep 17 00:00:00 2001 From: Streaky Date: Wed, 1 Jul 2026 18:54:45 +0100 Subject: [PATCH 08/26] post phase 1 cleanup/rejig --- acme_api/config.py | 65 +++++++++------- acme_api/logging.py | 13 +++- acme_api/main.py | 5 +- acme_api/middleware.py | 15 +++- config.example.yaml | 2 +- docs/plan.md | 159 ++++++++++++++++++++++----------------- pyproject.toml | 5 +- requirements-dev.txt | 14 ---- tests/conftest.py | 6 ++ tests/test_config.py | 49 ++++++++++-- tests/test_health.py | 3 +- tests/test_logging.py | 12 +++ tests/test_main.py | 1 + tests/test_middleware.py | 31 +++++++- 14 files changed, 253 insertions(+), 127 deletions(-) diff --git a/acme_api/config.py b/acme_api/config.py index b8cf074..9522724 100644 --- a/acme_api/config.py +++ b/acme_api/config.py @@ -12,24 +12,30 @@ from typing import Any, Literal import yaml -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field -class LogConfig(BaseModel): +class StrictConfigModel(BaseModel): + """Base config model that rejects unknown keys.""" + + model_config = ConfigDict(extra="forbid") + + +class LogConfig(StrictConfigModel): """Logging configuration.""" level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO" format: Literal["json", "text"] = "json" -class DatabaseConfig(BaseModel): +class DatabaseConfig(StrictConfigModel): """SQLite database configuration.""" url: str = "sqlite+aiosqlite:///./data/acme.db" pool_size: int = Field(default=5, ge=1) -class DeploymentConfig(BaseModel): +class DeploymentConfig(StrictConfigModel): """Certificate filesystem deployment configuration.""" directory: Path = Path("/certificates") @@ -37,7 +43,7 @@ class DeploymentConfig(BaseModel): permissions_key: int = 0o600 -class DnsProviderConfig(BaseModel): +class DnsProviderConfig(StrictConfigModel): """DNS provider alias configuration. Attributes: @@ -52,7 +58,7 @@ class DnsProviderConfig(BaseModel): env_vars_file_path: Path -class AcmeAccountConfig(BaseModel): +class AcmeAccountConfig(StrictConfigModel): """ACME account configuration. Attributes: @@ -70,21 +76,23 @@ class AcmeAccountConfig(BaseModel): account_key_path: Path | None = None -class AcmeConfig(BaseModel): +class AcmeConfig(StrictConfigModel): """acme.sh binary and state directory configuration.""" binary_path: str = "/usr/local/bin/acme.sh" home_dir: Path = Path("/acmesh") -class RenewalConfig(BaseModel): +class RenewalConfig(StrictConfigModel): """Automatic renewal scheduling configuration.""" + enabled: bool = True + check_interval_hours: int = Field(default=24, ge=1) window_days: int = Field(default=30, ge=1) max_retries: int = Field(default=3, ge=0) -class AppSettings(BaseModel): +class AppSettings(StrictConfigModel): """Top-level application settings loaded from config.yaml. Attributes: @@ -122,25 +130,17 @@ def check(self) -> None: f"got: {self.database.url!r}" ) - # Deployment directory should exist (or be creatable) + # Deployment directory should exist. Creation is handled separately so + # validation remains free of runtime side effects. if not self.deployment.directory.exists(): - try: - self.deployment.directory.mkdir(parents=True, exist_ok=True) - except OSError as exc: - errors.append( - f"deployment.directory '{self.deployment.directory}' " - f"does not exist and could not be created: {exc}" - ) + errors.append( + f"deployment.directory '{self.deployment.directory}' does not exist" + ) - # ACME home directory should exist (or be creatable) + # ACME home directory should exist. Creation is handled separately so + # validation remains free of runtime side effects. if not self.acme.home_dir.exists(): - try: - self.acme.home_dir.mkdir(parents=True, exist_ok=True) - except OSError as exc: - errors.append( - f"acme.home_dir '{self.acme.home_dir}' " - f"does not exist and could not be created: {exc}" - ) + errors.append(f"acme.home_dir '{self.acme.home_dir}' does not exist") # Check for duplicate DNS provider names provider_names = [p.name for p in self.dns_providers] @@ -165,6 +165,12 @@ def check(self) -> None: raise ValueError("\n".join(errors)) +def prepare_runtime_paths(settings: AppSettings) -> None: + """Create runtime directories required by the application.""" + settings.deployment.directory.mkdir(parents=True, exist_ok=True) + settings.acme.home_dir.mkdir(parents=True, exist_ok=True) + + def load_config(path: Path | None = None) -> AppSettings: """Load and validate configuration from a YAML file. @@ -186,9 +192,10 @@ def load_config(path: Path | None = None) -> AppSettings: else: path = Path("./config.yaml") - raw: dict[str, Any] = {} - if path.exists(): - with open(path, encoding="utf-8") as fh: - raw = yaml.safe_load(fh) or {} + if not path.exists(): + raise FileNotFoundError(f"config file not found: {path}") + + with open(path, encoding="utf-8") as fh: + raw: dict[str, Any] = yaml.safe_load(fh) or {} return AppSettings(**raw) diff --git a/acme_api/logging.py b/acme_api/logging.py index 4e7f904..41c731f 100644 --- a/acme_api/logging.py +++ b/acme_api/logging.py @@ -13,6 +13,8 @@ # Thread-safe storage for the current request's correlation ID request_id: ContextVar[str | None] = ContextVar("request_id", default=None) +_STANDARD_LOG_RECORD_ATTRS = set(logging.makeLogRecord({}).__dict__) + class JSONFormatter(logging.Formatter): """A logging formatter that outputs in JSON format.""" @@ -27,13 +29,18 @@ def format(self, record: logging.LogRecord) -> str: "request_id": request_id.get(), } - if hasattr(record, "extra"): - log_data.update(record.extra) + for key, value in record.__dict__.items(): + if key not in _STANDARD_LOG_RECORD_ATTRS and key != "extra": + log_data[key] = value + + extra_data: Any = getattr(record, "extra", None) + if isinstance(extra_data, dict): + log_data.update(extra_data) if record.exc_info: log_data["exception"] = self.formatException(record.exc_info) - return json.dumps(log_data) + return json.dumps(log_data, default=str) def setup_logging(level: str, format_type: str = "json") -> None: diff --git a/acme_api/main.py b/acme_api/main.py index 8c1d85c..462b00d 100644 --- a/acme_api/main.py +++ b/acme_api/main.py @@ -14,7 +14,7 @@ from fastapi import FastAPI, Request, Response from fastapi.responses import JSONResponse -from acme_api.config import AppSettings, load_config +from acme_api.config import AppSettings, load_config, prepare_runtime_paths from acme_api.logging import setup_logging from acme_api.middleware import RequestIdMiddleware @@ -26,7 +26,8 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]: Configures structured logging and validates settings on startup. """ settings = app.state.settings - # Run config validation before anything else + # Prepare directories before validation checks that they are usable. + prepare_runtime_paths(settings) settings.check() # Configure structured logging according to settings setup_logging(level=settings.log.level, format_type=settings.log.format) diff --git a/acme_api/middleware.py b/acme_api/middleware.py index 08b67f9..da12cad 100644 --- a/acme_api/middleware.py +++ b/acme_api/middleware.py @@ -35,8 +35,10 @@ async def __call__( await self.app(scope, receive, send) return - rid = str(uuid4()) + rid = self._get_request_id(scope) token = _request_id_ctxvar.set(rid) + state = scope.setdefault("state", {}) + state["request_id"] = rid # Wrap send to inject the X-Request-ID response header headers_sent = False @@ -58,3 +60,14 @@ async def wrapped_send(message: dict[str, Any]) -> None: await self.app(scope, receive, wrapped_send) finally: _request_id_ctxvar.reset(token) + + @staticmethod + def _get_request_id(scope: dict[str, Any]) -> str: + """Return the incoming request ID header or generate a new ID.""" + headers = cast(list[tuple[bytes, bytes]], scope.get("headers", [])) + for key, value in headers: + if key.lower() == b"x-request-id": + decoded = value.decode("latin-1").strip() + if decoded: + return decoded + return str(uuid4()) diff --git a/config.example.yaml b/config.example.yaml index 013adda..d1dbac8 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -21,7 +21,7 @@ acme: renewal: enabled: true check_interval_hours: 24 - renewal_window_days: 30 + window_days: 30 max_retries: 3 # DNS provider aliases referenced by name in the API. diff --git a/docs/plan.md b/docs/plan.md index 1df8aff..6fe7983 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -1,20 +1,20 @@ # Implementation Plan — acme.api v1 > Derived from `docs/outline.md`. Targets Python 3.14, strict mypy, 80% per-file coverage gate. -> Greenfield codebase: `acme_api/` and `tests/` are currently empty. Infrastructure (Makefile, pyproject.toml) exists. +> Phase 0/1 foundation exists: package skeleton, config loading, structured logging, request IDs, health endpoint, and tests are wired. --- ## Phase 0 — Dependencies & Project Wiring -**Goal:** Resolve the runtime dependency list and wire a working entry point so `make dev` + `make test` passes on an empty package. +**Goal:** Resolve the runtime dependency list and wire a working entry point so `make dev` + `make test` passes on the package foundation. - Update `pyproject.toml` `[project.dependencies]`: - FastAPI, Uvicorn (ASGI server) - SQLAlchemy 2.x (async SQLite) - APScheduler (renewal jobs) - Pydantic Settings (`pydantic-settings`) — config loading beyond flat YAML - - HTTPX (webhook delivery; acme.sh subprocess parsing is local but webhooks are HTTP) + - HTTPX2 (webhook delivery; acme.sh subprocess parsing is local but webhooks are HTTP) - Prometheus Client (`prometheus-client`) - Passlib + bcrypt (API key hashing) - `aiofiles` (async filesystem writes for atomic deployment) @@ -25,7 +25,7 @@ - `tests/conftest.py` (shared pytest fixtures, async support via anyio) - Create placeholder `GET /health` returning `{"status": "ok"}` to verify the pipeline. -**Acceptance:** `make combined-check` passes; Docker build succeeds with a health endpoint responding 200 OK. +**Acceptance:** `make combined-check` passes; health endpoint responds 200 OK in tests. --- @@ -37,31 +37,56 @@ - Pydantic model representing the full config schema (ACME paths, SQLite DSN, cert deployment dir, DNS providers, ACME accounts, log level). - Config loader reads `config.yaml` from a configurable path (`ACME_API_CONFIG` env var). - Validation on startup: reject missing required fields with clear errors. + - Unknown config keys are rejected so stale examples or typos fail fast. + - Missing config files fail fast instead of silently falling back to defaults. + - Runtime directory creation is separate from config validation. - Structured JSON logging setup (`acme_api/logging.py`): - JSON-formatted records to stdout and optionally to file. - Configurable log level via config. - - Request ID context propagation (middleware injects per-request correlation ID). + - Request ID context propagation (middleware preserves inbound `X-Request-ID` or injects a new per-request correlation ID). - Example `config.yaml` shipped in the repo root as a reference (`config.example.yaml`). **Acceptance:** App starts with valid/invalid configs; structured logs emitted for lifecycle events; config validation errors surfaced at startup. --- +## v1 Persistence Boundary + +Mutable runtime state lives in SQLite. Administrator-managed external integrations live in `config.yaml`. + +- **Config-owned in v1:** + - ACME accounts. + - DNS provider aliases and credential file paths. + - Initial/bootstrap API keys, if needed. +- **DB-owned in v1:** + - Certificates and their lifecycle state. + - Renewal attempts / scheduling metadata. + - Webhook configurations. + - Event/audit log. + - API keys, if API-managed key lifecycle is implemented in v1. + +Accounts and providers are exposed through read-only API endpoints, but are not API-mutated in v1. + +--- + ## Phase 2 — Database Layer & Data Models -**Goal:** SQLite-backed ORM models with migrations for all persistent entities defined in the outline (certificates, accounts, providers, renewal schedule, webhook configuration, audit log). +**Goal:** SQLite-backed async database foundation and migrations for mutable runtime state. - `acme_api/db.py`: async SQLAlchemy engine setup, session factory, connection pooling. +- SQLite pragmas for service usage: + - WAL mode. + - Foreign keys enabled. + - Conservative pool configuration. +- Alembic integration for migrations: initial migration covering foundation tables. - `acme_api/models/` package: - **Certificate**: id (UUID), name, domains (JSON array), acme_account_ref, dns_provider_ref, key_algorithm, expiry_date, status (Pending | Issuing | Valid | Renewing | Failed | Revoked), created_at, updated_at. - - **ACMEAccount**: name, server_url (LE prod/staging, ZeroSSL, Buypass), account_key_path (filesystem path managed by acme.sh). - - **DNSProvider**: name, provider_name (acme.sh provider alias e.g. `cloudflare`), env_vars_file_path (path to file with DNS credentials — avoids passing secrets in memory from the API layer). - - **WebhookConfig**: id, url, events (JSON array of event types it subscribes to), secret (for HMAC signing). - **Event** (audit log): id, timestamp, event_type, certificate_ref (nullable), details (JSON), status. -- Alembic integration for migrations: initial migration covering all tables. -- Pydantic schemas (`acme_api/schemas/`) mirroring each model for API serialization with validators (domain format, RFC-compliant expiry parsing). + - **RenewalAttempt** or renewal metadata table: certificate_ref, attempted_at, status, error category/details, next_retry_at. +- Pydantic schemas (`acme_api/schemas/`) for certificate/event serialization with validators (domain format, RFC-compliant expiry parsing). +- Config-backed read schemas for ACME accounts and DNS providers. -**Acceptance:** All models create/migrate cleanly; CRUD operations on every entity work via async session; schemas serialize/deserialize round-trip. +**Acceptance:** DB engine/session wiring works; migrations create all Phase 2 tables cleanly; certificate/event CRUD works via async session; schemas serialize/deserialize round-trip; config-owned accounts/providers are available through typed read models. --- @@ -81,16 +106,34 @@ - Error handling: distinguishes transient failures (DNS propagation) from terminal errors (account invalid). - Configuration-driven ACME home directory (`/acmesh` in container) mounted persistently. -**Acceptance:** Backend can register an account against LE staging; issue and renew a certificate with DNS-01; parse expiry dates correctly. Mock backend available for tests without acme.sh installed. +**Acceptance:** Mock subprocess tests cover command construction, success parsing, expiry parsing, and transient/terminal error mapping. Mock backend available for API tests without acme.sh installed. Optional integration test can register/issue/renew against Pebble or LE staging when DNS credentials are available. --- -## Phase 4 — Core API Endpoints (Certificates, Accounts, Providers, Events) +## Phase 4 — Authentication & Authorization (API Keys) + +**Goal:** API key-based auth with role-based access control (Admin, Operator, Read Only). Auth is introduced before the user-facing API endpoints so routes, tests, and OpenAPI metadata are built around the final access model. + +- `acme_api/auth/`: + - **APIKey model** if API-managed key lifecycle is in v1: id, name, hashed_key, role (`admin`, `operator`, `readonly`), created_at, expires_at (nullable). + - Bootstrap/config keys may be defined in `config.yaml`; DB-backed keys can be added for API-managed lifecycle. + - **Middleware/dependency** validates `Authorization: Bearer ` header and extracts role. + - **RBAC enforcement** via FastAPI dependencies: + - Admin: all endpoints. + - Operator: create/renew certificates, view status, view events. + - Read Only: GET endpoints only. + - API key hashing via Passlib + bcrypt for storage. + +**Acceptance:** Unauthenticated requests return 401; insufficient role returns 403; role dependencies are reusable by later route modules; auth behavior is covered by an RBAC test matrix. + +--- + +## Phase 5 — Core API Endpoints (Certificates, Accounts, Providers, Events) **Goal:** Full CRUD REST API backed by the database layer and ACME backend abstraction. OpenAPI metadata on all endpoints per outline spec. ### Certificates (`/v1/certificates`) -- `POST /v1/certificates` — create certificate request (name, domains, acme_account, dns_provider, key_algorithm). Transitions: Pending → Issuing → Valid or Failed. Triggers immediate issuance via backend. +- `POST /v1/certificates` — create certificate request (name, domains, acme_account, dns_provider, key_algorithm). Creates a DB record and starts issuance work. Returns `202 Accepted` with certificate id/status instead of blocking on DNS propagation. - `GET /v1/certificates` — list with pagination (`?offset=&limit=`), filter by status, domain search. - `GET /v1/certificates/{id}` — single certificate detail including expiry and status. - `DELETE /v1/certificates/{id}` — soft delete (mark as Revoked; remove from renewal schedule). @@ -108,13 +151,15 @@ ### Implementation Details - FastAPI router structure: `acme_api/routes/certificates.py`, `accounts.py`, `providers.py`, `events.py`. - Dependency injection for DB sessions and backend instances. +- Auth/RBAC dependencies applied at route level. +- Issuance state transitions: Pending → Issuing → Valid or Failed. - OpenAPI metadata on all endpoints (tags, summary, responses). **Acceptance:** All endpoints respond with correct status codes; input validation via Pydantic schemas; database CRUD wired end-to-end; OpenAPI docs at `/docs` reflect the API. --- -## Phase 5 — Certificate Filesystem Deployment +## Phase 6 — Certificate Filesystem Deployment **Goal:** Atomic deployment of certificate artifacts to the shared filesystem (`/certificates//`). @@ -123,7 +168,7 @@ 1. Write `.pem.tmp` files to a temp directory in the same filesystem. 2. `os.fsync()` each file handle. 3. `os.rename()` (atomic on POSIX) to final paths. - 4. Emit webhook event (Phase 7 hook). + 4. Emit webhook event (Phase 8 hook). - File layout per domain: ``` /certificates// @@ -139,7 +184,7 @@ --- -## Phase 6 — Renewal Scheduler +## Phase 7 — Renewal Scheduler **Goal:** Automatic renewal of certificates before expiry using APScheduler. State tracked internally per outline (Pending → Issuing → Valid → Renewing → Valid/Failed). @@ -155,38 +200,21 @@ --- -## Phase 7 — Webhook Notifications +## Phase 8 — Webhook Notifications **Goal:** HTTP webhook delivery for all lifecycle events with HMAC signing and retries. Events per outline: `certificate.created`, `.issued`, `.renewed`, `.failed`, `.expiring`, `.revoked`. +- `WebhookConfig` DB model: id, url, events (JSON array of event types it subscribes to), secret (for HMAC signing), created_at, updated_at, enabled. - `acme_api/webhooks.py`: - Payload structure per outline spec (event, certificate name, expiry, domains). - Per-webhook HMAC-SHA256 signature in `X-Webhook-Signature` header. - - Async HTTP delivery via HTTPX with timeout and retry logic (configurable: max retries, backoff). + - Async HTTP delivery via HTTPX2 with timeout and retry logic (configurable: max retries, backoff). - Failed deliveries logged to the Event table for auditability. **Acceptance:** Webhooks fire on all lifecycle events; payload matches spec; HMAC signature verifiable by consumer; failed deliveries retried and logged. --- -## Phase 8 — Authentication & Authorization (API Keys) - -**Goal:** API key-based auth with role-based access control (Admin, Operator, Read Only). Per outline: v1 supports API Keys only. - -- `acme_api/auth/`: - - **APIKey model**: id, name, hashed_key, role (`admin`, `operator`, `readonly`), created_at, expires_at (nullable). - - For v1, keys defined in `config.yaml` are sufficient; the DB model is prepared for future API-managed key lifecycle. - - **Middleware**: validates `Authorization: Bearer ` header; extracts role from config or DB lookup. - - **RBAC enforcement** via FastAPI dependencies: - - Admin: all endpoints (CRUD on certificates, accounts, providers, webhooks). - - Operator: create/renew certificates, view status, view events. - - Read Only: GET endpoints only. - - API key hashing via Passlib + bcrypt for storage. - -**Acceptance:** Unauthenticated requests return 401; insufficient role returns 403; all roles can access their permitted endpoints. - ---- - ## Phase 9 — Metrics & Health/Readiness Checks **Goal:** Prometheus metrics endpoint and Kubernetes-ready health probes per outline spec. @@ -250,7 +278,7 @@ - **Docker smoke test**: container starts, health checks pass, API responds. - Fixtures in `tests/fixtures/`: sample config.yaml, mock DNS provider env files, test certificate data. -**Acceptance:** Integration tests run via `make test`; coverage gate met; E2E flow produces a real (staging) certificate deployed to the filesystem. +**Acceptance:** Integration tests run via `make test`; coverage gate met; default E2E flow works with mock/Pebble-compatible backends; real staging ACME tests are optional and gated on DNS credentials. --- @@ -259,31 +287,28 @@ Phases with no hard dependencies can be worked in parallel: ``` -Phase 0 ──┐ - ├──> Phase 1 ──> Phase 2 ──┐ - │ ├──> Phase 3 ──> Phase 4 - │ │ - └──────────────────────────┘ - │ - Phase 5 │ (parallel with Phase 6) -Phase 5 ──────────────────────/ │ Phase 6 ────────────────\ - ├──> Phase 7 ──────────────\ - │ - Phase 8 ──────────────────────┤ - ├──> Phase 9 - Phase 10 (can start after 4) │ - │ -Phase 11 ←────────────────────────────────────────────────────────/ - \ -Phase 12 ←──────────────────────────────────────────────────────────-/ +Phase 0 -> Phase 1 -> Phase 2 + ├──> Phase 3 + └──> Phase 4 + +Phase 2 + Phase 3 + Phase 4 -> Phase 5 + +Phase 5 -> Phase 6 + -> Phase 7 + -> Phase 8 + -> Phase 9 + -> Phase 10 + +Phase 6 + Phase 7 + Phase 8 + Phase 9 + Phase 10 -> Phase 11 -> Phase 12 ``` **Strict ordering:** - Phase 0 → Phase 1 → Phase 2 (config and DB must exist before anything else). -- Phase 2 + Phase 3 → Phase 4 (API needs both DB models and backend abstraction). -- Phase 4 → Phase 5, 6, 7 (deployment, scheduling, webhooks all act on certificates created by the API). -- Phase 5–9 can proceed in parallel once Phase 4 is complete. -- Phase 10 depends on a working application (Phase 4+). +- Phase 2 → Phase 4 (auth needs config and, if DB-backed keys are enabled, DB foundation). +- Phase 2 + Phase 3 + Phase 4 → Phase 5 (API needs DB models, backend abstraction, and final auth dependencies). +- Phase 5 → Phase 6, 7, 8 (deployment, scheduling, webhooks all act on certificates created by the API). +- Phase 6–10 can proceed in parallel once Phase 5 is complete. +- Phase 10 depends on a working application (Phase 5+). - Phase 11 and 12 are final polish — depend on everything else. --- @@ -295,16 +320,16 @@ Phase 12 ←────────────────────── | 0 | Unit: entry point, config parse | 80% | | 1 | Unit: config validation, log formatting | 80% | | 2 | Unit + Integration: ORM CRUD, migrations, schema validation | 90% | -| 3 | Unit (mock subprocess) + Integration (staging acme.sh) | 80% / 70% | -| 4 | Integration: API endpoints via TestClient | 85% | -| 5 | Unit: atomic write, permissions, metadata JSON | 90% | -| 6 | Unit (mock backend): scheduling logic, state transitions | 90% | -| 7 | Unit (mock HTTPX): payload construction, HMAC, retry | 85% | -| 8 | Integration: auth middleware, RBAC matrix | 90% | +| 3 | Unit: mock subprocess command construction, parsing, error mapping | 85% | +| 4 | Integration: auth middleware/dependencies, RBAC matrix | 90% | +| 5 | Integration: API endpoints via TestClient | 85% | +| 6 | Unit: atomic write, permissions, metadata JSON | 90% | +| 7 | Unit (mock backend): scheduling logic, state transitions | 90% | +| 8 | Unit (mock HTTPX2): payload construction, HMAC, retry | 85% | | 9 | Unit: metric counters, health checks | 85% | | 10 | Smoke test: container build + startup | — | | 11 | Regression: full `make combined-check` | 80% | -| 12 | E2E: full lifecycle against staging ACME | 70% | +| 12 | E2E: full lifecycle against mock/Pebble; optional staging ACME | 70% | --- diff --git a/pyproject.toml b/pyproject.toml index f088796..fe6bd7b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,11 +47,9 @@ dev = [ "isort", "mypy", "pytest", - "pytest-asyncio", "pytest-cov", "pylint", "types-PyYAML", - "httpx", ] [project.scripts] @@ -98,9 +96,10 @@ filterwarnings = [] addopts = [ "-vvv", "--tb=short", + "-p", + "no:asyncio", "--cov-report=term", ] -asyncio_mode = "auto" [tool.mypy] python_version = "3.14" diff --git a/requirements-dev.txt b/requirements-dev.txt index 3c67d9c..3a568ad 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -23,7 +23,6 @@ annotated-types==0.7.0 anyio==4.14.1 # via # -c requirements.txt - # httpx # httpx2 # starlette # watchfiles @@ -41,10 +40,6 @@ bcrypt==5.0.0 # passlib black==26.5.1 # via acme.api (pyproject.toml) -certifi==2026.6.17 - # via - # httpcore - # httpx click==8.4.2 # via # -c requirements.txt @@ -67,11 +62,8 @@ greenlet==3.5.3 h11==0.16.0 # via # -c requirements.txt - # httpcore # httpcore2 # uvicorn -httpcore==1.0.9 - # via httpx httpcore2==2.5.0 # via # -c requirements.txt @@ -80,8 +72,6 @@ httptools==0.8.0 # via # -c requirements.txt # uvicorn -httpx==0.28.1 - # via acme.api (pyproject.toml) httpx2==2.5.0 # via # -c requirements.txt @@ -90,7 +80,6 @@ idna==3.18 # via # -c requirements.txt # anyio - # httpx # httpx2 iniconfig==2.3.0 # via pytest @@ -158,10 +147,7 @@ pylint==4.0.6 pytest==8.4.2 # via # acme.api (pyproject.toml) - # pytest-asyncio # pytest-cov -pytest-asyncio==0.26.0 - # via acme.api (pyproject.toml) pytest-cov==7.1.0 # via acme.api (pyproject.toml) python-dotenv==1.2.2 diff --git a/tests/conftest.py b/tests/conftest.py index 877211b..8a0684b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -16,6 +16,12 @@ sys.path.insert(0, str(ROOT)) +@pytest.fixture() +def anyio_backend() -> str: + """Run AnyIO tests on asyncio only.""" + return "asyncio" + + @pytest.fixture() def app(tmp_path: Path) -> FastAPI: """Create a FastAPI app with an isolated temporary database path.""" diff --git a/tests/test_config.py b/tests/test_config.py index bcf5cd7..5bc5fbb 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -17,6 +17,7 @@ LogConfig, RenewalConfig, load_config, + prepare_runtime_paths, ) @@ -59,10 +60,13 @@ def test_defaults(self) -> None: class TestRenewalConfig: def test_defaults(self) -> None: cfg = RenewalConfig() + assert cfg.enabled is True + assert cfg.check_interval_hours == 24 assert cfg.window_days == 30 def test_custom_window(self) -> None: - cfg = RenewalConfig(window_days=60, max_retries=5) + cfg = RenewalConfig(check_interval_hours=12, window_days=60, max_retries=5) + assert cfg.check_interval_hours == 12 assert cfg.window_days == 60 assert cfg.max_retries == 5 @@ -120,10 +124,10 @@ def test_partial_override(self) -> None: class TestLoadConfig: - def test_missing_file_returns_defaults(self, tmp_path: Path) -> None: - """When no config file exists at the given path, return schema defaults.""" - cfg = load_config(tmp_path / "nope.yaml") - assert isinstance(cfg, AppSettings) + def test_missing_file_raises(self, tmp_path: Path) -> None: + """Missing config paths fail fast.""" + with pytest.raises(FileNotFoundError): + load_config(tmp_path / "nope.yaml") def test_load_valid_yaml(self, tmp_path: Path) -> None: config_file = tmp_path / "config.yaml" @@ -163,6 +167,14 @@ def test_invalid_log_level(self, tmp_path: Path) -> None: with pytest.raises(ValueError): load_config(config_file) + def test_unknown_key_raises(self, tmp_path: Path) -> None: + config_file = tmp_path / "bad.yaml" + with open(config_file, "w") as fh: + yaml.dump({"renewal": {"renewal_window_days": 30}}, fh) + + with pytest.raises(ValueError, match="renewal_window_days"): + load_config(config_file) + def test_fallback_to_cwd(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: config_file = tmp_path / "config.yaml" with open(config_file, "w") as fh: @@ -182,8 +194,31 @@ def test_validate_passes_with_tmp_dirs(self, tmp_path: Path) -> None: acme=AcmeConfig(home_dir=tmp_path / "acmesh"), ) # Should not raise + prepare_runtime_paths(settings) settings.check() + def test_prepare_runtime_paths_creates_dirs(self, tmp_path: Path) -> None: + settings = AppSettings( + database=DatabaseConfig(url=f"sqlite+aiosqlite:///{tmp_path}/test.db"), + deployment=DeploymentConfig(directory=tmp_path / "certs"), + acme=AcmeConfig(home_dir=tmp_path / "acmesh"), + ) + + prepare_runtime_paths(settings) + + assert settings.deployment.directory.is_dir() + assert settings.acme.home_dir.is_dir() + + def test_validate_missing_runtime_dir(self, tmp_path: Path) -> None: + settings = AppSettings( + database=DatabaseConfig(url=f"sqlite+aiosqlite:///{tmp_path}/test.db"), + deployment=DeploymentConfig(directory=tmp_path / "missing-certs"), + acme=AcmeConfig(home_dir=tmp_path / "missing-acmesh"), + ) + + with pytest.raises(ValueError, match="deployment.directory"): + settings.check() + def test_validate_duplicate_dns_provider_names(self, tmp_path: Path) -> None: settings = AppSettings( database=DatabaseConfig(url=f"sqlite+aiosqlite:///{tmp_path}/test.db"), @@ -200,6 +235,7 @@ def test_validate_duplicate_dns_provider_names(self, tmp_path: Path) -> None: ), ], ) + prepare_runtime_paths(settings) with pytest.raises(ValueError, match="duplicate"): settings.check() @@ -213,6 +249,7 @@ def test_validate_duplicate_acme_account_names(self, tmp_path: Path) -> None: AcmeAccountConfig(name="dup", server_url="https://b.com"), ], ) + prepare_runtime_paths(settings) with pytest.raises(ValueError, match="duplicate"): settings.check() @@ -222,6 +259,7 @@ def test_validate_bad_database_url(self, tmp_path: Path) -> None: deployment=DeploymentConfig(directory=tmp_path / "certs"), acme=AcmeConfig(home_dir=tmp_path / "acmesh"), ) + prepare_runtime_paths(settings) with pytest.raises(ValueError, match="database.url"): settings.check() @@ -237,5 +275,6 @@ def test_validate_missing_env_vars_file(self, tmp_path: Path) -> None: ), ], ) + prepare_runtime_paths(settings) with pytest.raises(ValueError, match="env_vars_file_path"): settings.check() diff --git a/tests/test_health.py b/tests/test_health.py index 4b9916c..51f69a1 100644 --- a/tests/test_health.py +++ b/tests/test_health.py @@ -2,11 +2,12 @@ from __future__ import annotations +import pytest from httpx import AsyncClient +@pytest.mark.anyio async def test_health_returns_ok(client: AsyncClient) -> None: - """GET /health should return 200 with status ok.""" response = await client.get("/health") assert response.status_code == 200 diff --git a/tests/test_logging.py b/tests/test_logging.py index a0ace06..70fd5ee 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -69,6 +69,18 @@ def test_includes_extra_data(self) -> None: assert data["user"] == "admin" assert data["action"] == "login" + def test_includes_standard_logging_extra_fields(self) -> None: + fmt = JSONFormatter() + record = logging.LogRecord( + name="test", level=logging.INFO, pathname="", lineno=1, + msg="hello", args=(), exc_info=None, + ) + record.cert_id = "cert-123" + + data = json.loads(fmt.format(record)) + + assert data["cert_id"] == "cert-123" + def test_includes_exception_info(self) -> None: fmt = JSONFormatter() exc = ValueError("oops") diff --git a/tests/test_main.py b/tests/test_main.py index 9d88172..1f851ab 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -58,6 +58,7 @@ async def boom_route() -> str: class TestLifespan: + @pytest.mark.anyio async def test_lifespan_yields(self, settings: AppSettings) -> None: """Verify the lifespan context manager yields once.""" app = create_app(settings=settings) diff --git a/tests/test_middleware.py b/tests/test_middleware.py index 2febf92..44ae5cd 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -6,7 +6,7 @@ from typing import Any, Callable, Generator import pytest -from fastapi import FastAPI +from fastapi import FastAPI, Request from httpx import ASGITransport, AsyncClient from acme_api.config import AppSettings, DatabaseConfig, DeploymentConfig @@ -62,6 +62,35 @@ async def test_middleware_different_id_per_request(tmp_path: Path) -> None: assert h1 != h2 +@pytest.mark.anyio +async def test_middleware_preserves_incoming_request_id(tmp_path: Path) -> None: + """Incoming X-Request-ID is used as the correlation ID.""" + app = await _make_app(tmp_path) + transport = ASGITransport(app=app) + + async with AsyncClient(transport=transport, base_url="http://test") as client: + resp = await client.get("/health", headers={"X-Request-ID": "external-123"}) + + assert resp.headers["x-request-id"] == "external-123" + + +@pytest.mark.anyio +async def test_middleware_sets_request_state(tmp_path: Path) -> None: + """Request ID is available to handlers through request.state.""" + app = await _make_app(tmp_path) + + @app.get("/request-id") + async def request_id_route(request: Request) -> dict[str, str]: + return {"request_id": request.state.request_id} + + transport = ASGITransport(app=app) + + async with AsyncClient(transport=transport, base_url="http://test") as client: + resp = await client.get("/request-id", headers={"X-Request-ID": "state-123"}) + + assert resp.json() == {"request_id": "state-123"} + + @pytest.mark.anyio async def test_middleware_resets_context_variable(tmp_path: Path) -> None: """Context variable is reset to None after the request completes.""" From 5fde7893c2a61ac62f531f2ca303c1a7f8d8d518 Mon Sep 17 00:00:00 2001 From: Streaky Date: Thu, 2 Jul 2026 01:10:05 +0100 Subject: [PATCH 09/26] phase 2 first implementation --- .gitignore | 2 + acme_api/db.py | 89 ++++++++ acme_api/models/__init__.py | 29 +++ acme_api/models/base.py | 39 ++++ acme_api/models/certificate.py | 111 ++++++++++ acme_api/models/event.py | 71 +++++++ acme_api/models/renewal_attempt.py | 82 ++++++++ acme_api/schemas/__init__.py | 28 +++ acme_api/schemas/certificate.py | 102 ++++++++++ acme_api/schemas/config_readonly.py | 33 +++ acme_api/schemas/event.py | 41 ++++ alembic.ini | 149 ++++++++++++++ alembic/README | 1 + alembic/env.py | 128 ++++++++++++ alembic/script.py.mako | 28 +++ .../versions/8760d3a7fed0_initial_schema.py | 84 ++++++++ pyproject.toml | 1 + tests/test_alembic.py | 98 +++++++++ tests/test_crud.py | 191 ++++++++++++++++++ tests/test_db.py | 139 +++++++++++++ tests/test_schemas.py | 190 +++++++++++++++++ 21 files changed, 1636 insertions(+) create mode 100644 acme_api/db.py create mode 100644 acme_api/models/__init__.py create mode 100644 acme_api/models/base.py create mode 100644 acme_api/models/certificate.py create mode 100644 acme_api/models/event.py create mode 100644 acme_api/models/renewal_attempt.py create mode 100644 acme_api/schemas/__init__.py create mode 100644 acme_api/schemas/certificate.py create mode 100644 acme_api/schemas/config_readonly.py create mode 100644 acme_api/schemas/event.py create mode 100644 alembic.ini create mode 100644 alembic/README create mode 100644 alembic/env.py create mode 100644 alembic/script.py.mako create mode 100644 alembic/versions/8760d3a7fed0_initial_schema.py create mode 100644 tests/test_alembic.py create mode 100644 tests/test_crud.py create mode 100644 tests/test_db.py create mode 100644 tests/test_schemas.py diff --git a/.gitignore b/.gitignore index 727ace6..1701ffe 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ __pycache__ coverage-data/ .coverage + +data/ diff --git a/acme_api/db.py b/acme_api/db.py new file mode 100644 index 0000000..18ead7a --- /dev/null +++ b/acme_api/db.py @@ -0,0 +1,89 @@ +"""Async SQLAlchemy engine, session factory, and FastAPI DI dependency.""" + +from __future__ import annotations + +import contextlib +import typing + +from sqlalchemy import event as sa_event +from sqlalchemy.ext.asyncio import ( + AsyncEngine, + AsyncSession, + async_sessionmaker, + create_async_engine, +) + +from acme_api.config import AppSettings +from acme_api.models.base import Base + + +# --------------------------------------------------------------------------- +# Module-level handles populated by init_engine() +# --------------------------------------------------------------------------- +_SessionFactory: typing.Optional[async_sessionmaker[AsyncSession]] = None +_engine: typing.Optional[AsyncEngine] = None +def _set_sqlite_pragma( + dbapi_conn: typing.Any, connection_record: typing.Any +) -> None: + """Apply SQLite pragmas on each new aiosqlite connection.""" + cursor = dbapi_conn.cursor() + try: + cursor.execute("PRAGMA journal_mode=WAL") + result = cursor.fetchone() + assert result[0] == "wal", f"journal_mode WAL failed: {result}" + cursor.execute("PRAGMA foreign_keys=ON") + finally: + cursor.close() + + +def init_engine(settings: AppSettings) -> AsyncEngine: + """Create and configure the async SQLAlchemy engine. + + Parameters + ---------- + settings: + Application settings providing database.url, pool_size, etc. + + Returns + ------- + AsyncEngine configured with SQLite pragmas and pool settings. + """ + global _engine, _SessionFactory # noqa: PLW0603 + + engine = create_async_engine( + url=settings.database.url, + echo=False, + pool_size=settings.database.pool_size, + max_overflow=10, + pool_recycle=3600, + pool_pre_ping=True, + ) + + # Apply pragmas on each new connection. + sa_event.listen(engine.sync_engine, "connect", _set_sqlite_pragma) + + _engine = engine + _SessionFactory = async_sessionmaker( + bind=engine, + class_=AsyncSession, + expire_on_commit=False, + ) + + return engine + + +@contextlib.asynccontextmanager +async def get_db() -> typing.AsyncIterator[AsyncSession]: + """FastAPI dependency that yields an async database session.""" + assert _SessionFactory is not None, "Call init_engine() before mounting routes." + async with _SessionFactory() as session: + yield session + + +async def init_db(engine: AsyncEngine) -> None: + """Create all tables defined by the Base metadata. + + Intended for test/bootstrap use; Alembic migrations handle production. + """ + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) diff --git a/acme_api/models/__init__.py b/acme_api/models/__init__.py new file mode 100644 index 0000000..a79032f --- /dev/null +++ b/acme_api/models/__init__.py @@ -0,0 +1,29 @@ +"""SQLAlchemy model registry for the ACME API data layer. + +Public API — import everything you need from this single entry point:: + + from acme_api.models import ( + Base, + TimestampMixin, + Certificate, + CertificateStatus, + Event, + RenewalAttempt, + ) +""" + +from __future__ import annotations + +from acme_api.models.base import Base, TimestampMixin +from acme_api.models.certificate import Certificate, CertificateStatus +from acme_api.models.event import Event +from acme_api.models.renewal_attempt import RenewalAttempt + +__all__ = [ + "Base", + "Certificate", + "CertificateStatus", + "Event", + "RenewalAttempt", + "TimestampMixin", +] diff --git a/acme_api/models/base.py b/acme_api/models/base.py new file mode 100644 index 0000000..14910be --- /dev/null +++ b/acme_api/models/base.py @@ -0,0 +1,39 @@ +"""Declarative base and timestamp mixin for the data layer.""" + +from __future__ import annotations + +import datetime as _dt + +from sqlalchemy import DateTime, func +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class Base(DeclarativeBase): + """SQLAlchemy 2.x declarative base class. + + All model classes inherit from this base to register with the + SQLAlchemy metadata registry and gain mapper support. + """ + + +class TimestampMixin: + """Mixin that adds ``created_at`` and ``updated_at`` audit columns. + + ``created_at`` is set automatically by the database on insert via a + server-side default. ``updated_at`` is set on every row update by the + ORM-level ``onupdate`` callback. Both are timezone-aware UTC datetimes. + """ + + created_at: Mapped[_dt.datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + doc="Timestamp when the row was first inserted.", + ) + updated_at: Mapped[_dt.datetime] = mapped_column( + DateTime(timezone=True), + onupdate=func.now(), + server_default=func.now(), + nullable=False, + doc="Timestamp of the most recent update to this row.", + ) diff --git a/acme_api/models/certificate.py b/acme_api/models/certificate.py new file mode 100644 index 0000000..b613f76 --- /dev/null +++ b/acme_api/models/certificate.py @@ -0,0 +1,111 @@ +"""Certificate model — the core entity representing an ACME-managed TLS cert.""" + +from __future__ import annotations + +import datetime as _dt +import enum +import uuid as _uuid + +from sqlalchemy import DateTime, Enum, JSON, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from acme_api.models.base import Base, TimestampMixin + + +class CertificateStatus(enum.StrEnum): + """Lifecycle states a certificate can occupy.""" + + PENDING = "pending" + ISSUING = "issuing" + VALID = "valid" + RENEWING = "renewing" + FAILED = "failed" + REVOKED = "revoked" + + +class Certificate(Base, TimestampMixin): + """Row representing a single ACME-managed TLS certificate. + + Each certificate is linked to an ACME account and DNS provider by config + alias (``acme_account_ref``, ``dns_provider_ref``) rather than a foreign-key, + keeping the data layer decoupled from deployment configuration. + + Timestamps (``created_at``, ``updated_at``) are provided by ``TimestampMixin`` + via the model hierarchy — see :class:`base.TimestampMixin`. + """ + + __tablename__ = "certificates" + + # -- primary key ---------------------------------------------------------- + + id: Mapped[_uuid.UUID] = mapped_column( + primary_key=True, + default=_uuid.uuid4, + doc="Unique identifier for the certificate.", + ) + + # -- identity & target domains -------------------------------------------- + + name: Mapped[str] = mapped_column( + String(255), + unique=True, + index=True, + nullable=False, + doc="Human-readable label / alias for the certificate.", + ) + domains: Mapped[list[str]] = mapped_column( + JSON, + nullable=False, + doc="JSON array of domain strings covered by this certificate.", + ) + + # -- external references (config aliases) --------------------------------- + + acme_account_ref: Mapped[str] = mapped_column( + String(128), + nullable=False, + doc="Alias referencing the ACME account configuration block.", + ) + dns_provider_ref: Mapped[str] = mapped_column( + String(128), + nullable=False, + doc="Alias referencing the DNS provider configuration block.", + ) + + # -- key parameters ------------------------------------------------------- + + key_algorithm: Mapped[str] = mapped_column( + String(32), + default="ecdsa", + nullable=False, + doc="Key algorithm used (e.g. 'ecdsa', 'rsa-2048', 'rsa-4096').", + ) + + # -- expiry & status ------------------------------------------------------ + + expiry_date: Mapped[_dt.datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + doc="UTC datetime when the certificate expires. NULL before issuance.", + ) + status: Mapped[CertificateStatus] = mapped_column( + Enum(CertificateStatus), + default=CertificateStatus.PENDING, + nullable=False, + doc="Current lifecycle state of the certificate.", + ) + + # -- relationships -------------------------------------------------------- + + events = relationship( + "acme_api.models.event.Event", + back_populates="certificate", + lazy="selectin", + doc="Audit log entries for this certificate.", + ) + renewal_attempts = relationship( + "acme_api.models.renewal_attempt.RenewalAttempt", + back_populates="certificate", + lazy="selectin", + doc="Records of each renewal attempt made against this certificate.", + ) diff --git a/acme_api/models/event.py b/acme_api/models/event.py new file mode 100644 index 0000000..51d355d --- /dev/null +++ b/acme_api/models/event.py @@ -0,0 +1,71 @@ +"""Event model — audit-log entries for certificate lifecycle events.""" + +from __future__ import annotations + +import datetime as _dt +import uuid as _uuid + +from sqlalchemy import DateTime, ForeignKey, JSON, String, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from acme_api.models.base import Base + + +class Event(Base): + """Row representing a single audit-log event. + + Events are attached to certificates via ``certificate_id`` but may also be + standalone when no specific certificate is involved (e.g. system-level + bootstrap events). + """ + + __tablename__ = "events" + + # -- primary key ---------------------------------------------------------- + + id: Mapped[_uuid.UUID] = mapped_column( + primary_key=True, + default=_uuid.uuid4, + doc="Unique identifier for the event.", + ) + + # -- event metadata ------------------------------------------------------- + + timestamp: Mapped[_dt.datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + doc="UTC time at which this event occurred.", + ) + event_type: Mapped[str] = mapped_column( + String(64), + index=True, + nullable=False, + doc="Machine-readable event category (e.g. 'certificate.created').", + ) + + # -- certificate association ---------------------------------------------- + + certificate_id: Mapped[_uuid.UUID | None] = mapped_column( + ForeignKey("certificates.id"), + nullable=True, + doc="Optional link to the certificate this event concerns.", + ) + + # -- payload -------------------------------------------------------------- + + details: Mapped[dict[str, object]] = mapped_column( + JSON, + default=dict, + nullable=False, + doc="Arbitrary key-value context for the event.", + ) + + # -- relationships -------------------------------------------------------- + + certificate = relationship( + "acme_api.models.certificate.Certificate", + back_populates="events", + lazy="selectin", + doc="Certificate associated with this event, if any.", + ) diff --git a/acme_api/models/renewal_attempt.py b/acme_api/models/renewal_attempt.py new file mode 100644 index 0000000..a8f169d --- /dev/null +++ b/acme_api/models/renewal_attempt.py @@ -0,0 +1,82 @@ +"""RenewalAttempt model — records each certificate renewal attempt.""" + +from __future__ import annotations + +import datetime as _dt +import uuid as _uuid + +from sqlalchemy import DateTime, ForeignKey, JSON, String, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from acme_api.models.base import Base + + +class RenewalAttempt(Base): + """Row representing a single attempt to renew a certificate. + + Each attempt is linked back to its parent certificate and records the + outcome along with error diagnostics when applicable. + """ + + __tablename__ = "renewal_attempts" + + # -- primary key ---------------------------------------------------------- + + id: Mapped[_uuid.UUID] = mapped_column( + primary_key=True, + default=_uuid.uuid4, + doc="Unique identifier for the renewal attempt.", + ) + + # -- certificate association ---------------------------------------------- + + certificate_id: Mapped[_uuid.UUID] = mapped_column( + ForeignKey("certificates.id"), + index=True, + nullable=False, + doc="Certificate this attempt was made against.", + ) + + # -- attempt metadata ----------------------------------------------------- + + attempted_at: Mapped[_dt.datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + doc="UTC time the renewal attempt was initiated.", + ) + status: Mapped[str] = mapped_column( + String(32), + nullable=False, + doc="Outcome of the attempt (e.g. 'success', 'failed', 'pending').", + ) + + # -- error diagnostics ---------------------------------------------------- + + error_category: Mapped[str | None] = mapped_column( + String(64), + nullable=True, + doc="High-level error classification when applicable.", + ) + error_details: Mapped[dict[str, object] | None] = mapped_column( + JSON, + nullable=True, + doc="Arbitrary diagnostic details for the error.", + ) + + # -- scheduling ----------------------------------------------------------- + + next_retry_at: Mapped[_dt.datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + doc="Scheduled UTC time for the next retry, if any.", + ) + + # -- relationships -------------------------------------------------------- + + certificate = relationship( + "acme_api.models.certificate.Certificate", + back_populates="renewal_attempts", + lazy="selectin", + doc="Certificate this attempt belongs to.", + ) diff --git a/acme_api/schemas/__init__.py b/acme_api/schemas/__init__.py new file mode 100644 index 0000000..3d01e9d --- /dev/null +++ b/acme_api/schemas/__init__.py @@ -0,0 +1,28 @@ +"""Pydantic API schemas for acme.api.""" + +from __future__ import annotations + +from acme_api.schemas.certificate import ( + CertificateCreate, + CertificateKeyAlgorithm, + CertificateList, + CertificateRead, + CertificateStatus, +) +from acme_api.schemas.config_readonly import AcmeAccountRead, DnsProviderRead +from acme_api.schemas.event import EventCreate, EventRead + +__all__ = [ + # certificate + "CertificateCreate", + "CertificateKeyAlgorithm", + "CertificateList", + "CertificateRead", + "CertificateStatus", + # config_readonly + "AcmeAccountRead", + "DnsProviderRead", + # event + "EventCreate", + "EventRead", +] diff --git a/acme_api/schemas/certificate.py b/acme_api/schemas/certificate.py new file mode 100644 index 0000000..819c780 --- /dev/null +++ b/acme_api/schemas/certificate.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +import re +import uuid +from datetime import datetime +from enum import StrEnum +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +class CertificateStatus(StrEnum): + """Lifecycle states a certificate can occupy.""" + + PENDING = "pending" + ISSUING = "issuing" + VALID = "valid" + RENEWING = "renewing" + FAILED = "failed" + REVOKED = "revoked" + + +CertificateKeyAlgorithm = Literal["ecdsa", "rsa-2048", "rsa-4096"] + + +class CertificateCreate(BaseModel): + """Payload for creating a new certificate. + + Attributes: + name: Human-readable label / alias for the certificate. + domains: List of DNS names covered by this certificate. + acme_account_ref: Alias referencing an ACME account in config.yaml. + dns_provider_ref: Alias referencing a DNS provider in config.yaml. + key_algorithm: Key algorithm (default ``ecdsa``). + """ + + name: str = Field(min_length=1, max_length=255) + domains: list[str] = Field(min_length=1) + acme_account_ref: str + dns_provider_ref: str + key_algorithm: CertificateKeyAlgorithm = "ecdsa" + + @field_validator("domains") + @classmethod + def _validate_domains(cls, v: list[str]) -> list[str]: + """Each domain must be a valid DNS name or wildcard.""" + pattern = re.compile( + r"^(?:\*\.)?([a-zA-Z0-9](?:[a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$" + ) + for domain in v: + if not pattern.match(domain): + raise ValueError( + f"Domain {domain!r} does not match a valid DNS label pattern." + ) + return v + + +class CertificateRead(BaseModel): + """Full certificate representation returned by GET endpoints. + + Attributes: + id: Unique identifier. + name: Human-readable label / alias. + domains: List of DNS names covered by this certificate. + acme_account_ref: Alias referencing an ACME account in config.yaml. + dns_provider_ref: Alias referencing a DNS provider in config.yaml. + key_algorithm: Key algorithm used for the certificate key pair. + expiry_date: UTC datetime when the certificate expires; ``None`` before issuance. + status: Current lifecycle state of the certificate. + created_at: Timestamp when the row was first inserted. + updated_at: Timestamp of the most recent update to this row. + """ + + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + name: str + domains: list[str] + acme_account_ref: str + dns_provider_ref: str + key_algorithm: str + expiry_date: datetime | None = None + status: CertificateStatus + created_at: datetime + updated_at: datetime + + +class CertificateList(BaseModel): + """Short form for list responses. + + Attributes: + id: Unique identifier. + name: Human-readable label / alias. + domains: List of DNS names covered by this certificate. + status: Current lifecycle state of the certificate. + expiry_date: UTC datetime when the certificate expires; ``None`` before issuance. + """ + + id: uuid.UUID + name: str + domains: list[str] + status: CertificateStatus + expiry_date: datetime | None = None diff --git a/acme_api/schemas/config_readonly.py b/acme_api/schemas/config_readonly.py new file mode 100644 index 0000000..138871f --- /dev/null +++ b/acme_api/schemas/config_readonly.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from pydantic import BaseModel + + +class AcmeAccountRead(BaseModel): + """Read-only schema reflecting config.yaml acme_accounts entries. + + Accounts are admin-defined in configuration and surfaced as a read-only list + so API consumers can reference them by name when creating certificates. + + Attributes: + name: Human-readable alias (e.g. ``letsencrypt-production``). + server_url: ACME directory URL for the account. + """ + + name: str + server_url: str + + +class DnsProviderRead(BaseModel): + """Read-only schema reflecting config.yaml dns_providers entries. + + DNS providers are admin-defined in configuration and surfaced as a read-only list + so API consumers can reference them by name when creating certificates. + + Attributes: + name: Human-readable alias (e.g. ``production``, ``staging``). + provider_name: acme.sh DNS API name (e.g. ``cloudflare``). + """ + + name: str + provider_name: str diff --git a/acme_api/schemas/event.py b/acme_api/schemas/event.py new file mode 100644 index 0000000..743f7ed --- /dev/null +++ b/acme_api/schemas/event.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import uuid +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + + +class EventCreate(BaseModel): + """Payload for creating a new event record. + + Attributes: + event_type: Machine-readable event category (e.g. ``certificate.created``). + certificate_id: Optional UUID of the certificate this event concerns. + details: Arbitrary key-value context for the event. + """ + + event_type: str = Field(min_length=1, max_length=64) + certificate_id: uuid.UUID | None = None + details: dict[str, Any] = Field(default_factory=dict) + + +class EventRead(BaseModel): + """Full event representation returned by GET endpoints. + + Attributes: + id: Unique identifier. + timestamp: UTC time at which this event occurred. + event_type: Machine-readable event category. + certificate_id: Optional UUID of the associated certificate. + details: Arbitrary key-value context for the event. + """ + + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + timestamp: datetime + event_type: str + certificate_id: uuid.UUID | None = None + details: dict[str, Any] = {} diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..9d09af9 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,149 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s +# Or organize into date-based subdirectories (requires recursive_version_locations = true) +# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the tzdata library which can be installed by adding +# `alembic[tz]` to the pip requirements. +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +sqlalchemy.url = sqlite+aiosqlite:///./data/acme.db + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/alembic/README b/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..6090bb7 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,128 @@ +"""Alembic environment configuration — migrations for acme.api. + +Reads model metadata from our SQLAlchemy ORM models and runs migrations via +a synchronous engine so that the standard ``alembic`` CLI commands work +without wrapping every call in an async runner. + +For SQLite + aiosqlite URLs, the driver is normalised to plain sqlite so +that synchronous inspection (autogenerate) succeeds against the same database +file used by the application at runtime. +""" + +from __future__ import annotations + +import re +import sys +from logging.config import fileConfig +from pathlib import Path + +from sqlalchemy import engine_from_config, pool + +from alembic import context + +# --------------------------------------------------------------------------- +# Alembic config +# --------------------------------------------------------------------------- + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# Ensure the project root (parent of this file) is on sys.path so that +# ``acme_api`` imports resolve correctly regardless of invocation directory. +_alembic_dir = Path(__file__).resolve().parent +_project_root = _alembic_dir.parent +if str(_project_root) not in sys.path: + sys.path.insert(0, str(_project_root)) + +# --------------------------------------------------------------------------- +# Model metadata (autogenerate support) +# --------------------------------------------------------------------------- + +from acme_api.models.base import Base # noqa: E402 + +target_metadata = Base.metadata + + +def _normalise_url(url: str) -> str: + """Convert an aiosqlite URL to plain sqlite for synchronous migrations. + + ``alembic revision --autogenerate`` runs synchronously, so the dialect + must be one that supports blocking I/O (sqlite3), not async (aiosqlite). + The resulting sync engine writes to the *same* database file, which is + safe because migration scripts execute serially. + """ + return re.sub(r"^sqlite\+aiosqlite:///", "sqlite:///", url) + + +def _get_url() -> str: + """Resolve the SQLAlchemy URL from Alembic config or application settings.""" + url = config.get_main_option("sqlalchemy.url") + if url != "driver://user:pass@localhost/dbname": + return _normalise_url(url) + + from acme_api.config import AppSettings, load_config # noqa: PLC0415 + + settings: AppSettings = load_config() + return _normalise_url(settings.database.url) + + +# --------------------------------------------------------------------------- +# Migration runners +# --------------------------------------------------------------------------- + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + Emits SQL to stdout; no database connection is required. + """ + url = _get_url() + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + render_as_batch=True, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: # noqa: PLR0915 + """Run migrations in 'online' mode using a synchronous engine.""" + url = _get_url() + + section = config.get_section(config.config_ini_section) or {} + section["sqlalchemy.url"] = url + + if "sqlite" in url: + connectable = engine_from_config( + section, + prefix="sqlalchemy.", + poolclass=pool.StaticPool, + ) + else: + connectable = engine_from_config( + section, + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + render_as_batch=True, + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/8760d3a7fed0_initial_schema.py b/alembic/versions/8760d3a7fed0_initial_schema.py new file mode 100644 index 0000000..882edee --- /dev/null +++ b/alembic/versions/8760d3a7fed0_initial_schema.py @@ -0,0 +1,84 @@ +"""initial_schema + +Revision ID: 8760d3a7fed0 +Revises: +Create Date: 2026-07-01 20:21:33.827923 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '8760d3a7fed0' +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('certificates', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('domains', sa.JSON(), nullable=False), + sa.Column('acme_account_ref', sa.String(length=128), nullable=False), + sa.Column('dns_provider_ref', sa.String(length=128), nullable=False), + sa.Column('key_algorithm', sa.String(length=32), nullable=False), + sa.Column('expiry_date', sa.DateTime(timezone=True), nullable=True), + sa.Column('status', sa.Enum('PENDING', 'ISSUING', 'VALID', 'RENEWING', 'FAILED', 'REVOKED', name='certificatestatus'), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('certificates', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_certificates_name'), ['name'], unique=True) + + op.create_table('events', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('timestamp', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('event_type', sa.String(length=64), nullable=False), + sa.Column('certificate_id', sa.Uuid(), nullable=True), + sa.Column('details', sa.JSON(), nullable=False), + sa.ForeignKeyConstraint(['certificate_id'], ['certificates.id'], ), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('events', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_events_event_type'), ['event_type'], unique=False) + + op.create_table('renewal_attempts', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('certificate_id', sa.Uuid(), nullable=False), + sa.Column('attempted_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('status', sa.String(length=32), nullable=False), + sa.Column('error_category', sa.String(length=64), nullable=True), + sa.Column('error_details', sa.JSON(), nullable=True), + sa.Column('next_retry_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['certificate_id'], ['certificates.id'], ), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('renewal_attempts', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_renewal_attempts_certificate_id'), ['certificate_id'], unique=False) + + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('renewal_attempts', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_renewal_attempts_certificate_id')) + + op.drop_table('renewal_attempts') + with op.batch_alter_table('events', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_events_event_type')) + + op.drop_table('events') + with op.batch_alter_table('certificates', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_certificates_name')) + + op.drop_table('certificates') + # ### end Alembic commands ### diff --git a/pyproject.toml b/pyproject.toml index fe6bd7b..3d3f562 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,6 +50,7 @@ dev = [ "pytest-cov", "pylint", "types-PyYAML", + "alembic", ] [project.scripts] diff --git a/tests/test_alembic.py b/tests/test_alembic.py new file mode 100644 index 0000000..561898e --- /dev/null +++ b/tests/test_alembic.py @@ -0,0 +1,98 @@ +"""Alembic migration verification — ensures `upgrade head` creates all expected tables.""" + +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + +import pytest +from sqlalchemy import create_engine, text + + +PROJECT_ROOT = Path(__file__).resolve().parent.parent + + +class TestAlembicMigration: + def test_initial_migration_applies(self, tmp_path: Path) -> None: + # 1. Prepare a fresh sqlite DB path + db_dir = tmp_path / "data" + db_dir.mkdir() + db_file = db_dir / "acme.db" + db_url = f"sqlite:///{db_file}" + + # 2. Write a minimal alembic.ini that points at our temp database + ini_content = f"""\ +[alembic] +script_location = {PROJECT_ROOT}/alembic +prepend_sys_path = . +path_separator = os +sqlalchemy.url = {db_url} + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S +""" # noqa: E501 + ini_path = tmp_path / "alembic_test.ini" + ini_path.write_text(ini_content, encoding="utf-8") + + # 3. Run alembic upgrade head in a subprocess pointing at our temp config + env = os.environ.copy() + env["PYTHONPATH"] = str(PROJECT_ROOT) + result = subprocess.run( + [".venv/bin/alembic", "-c", str(ini_path), "upgrade", "head"], + cwd=PROJECT_ROOT, + env=env, + text=True, + ) + assert result.returncode == 0, ( + f"alembic upgrade head failed:\n{result.stdout}\n{result.stderr}" + ) + + # 4. Verify that all expected tables were created by the migration + engine = create_engine(db_url) + try: + with engine.connect() as conn: + table_names = { + row[0] + for row in conn.execute( + text("SELECT name FROM sqlite_master WHERE type='table'") + ) + } + finally: + engine.dispose() + + expected_tables = {"certificates", "events", "renewal_attempts", "alembic_version"} + assert expected_tables.issubset(table_names), ( + f"Missing tables {expected_tables - table_names}; found {table_names}" + ) diff --git a/tests/test_crud.py b/tests/test_crud.py new file mode 100644 index 0000000..10dc3e4 --- /dev/null +++ b/tests/test_crud.py @@ -0,0 +1,191 @@ +"""Async CRUD tests for Certificate and Event models via SQLAlchemy session.""" + +from __future__ import annotations + +import uuid as _uuid +from pathlib import Path + +import pytest +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from acme_api.config import AppSettings, DatabaseConfig, DeploymentConfig +from acme_api.db import init_db, init_engine +from acme_api.models.certificate import Certificate, CertificateStatus +from acme_api.models.event import Event + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def settings(tmp_path: Path) -> AppSettings: + return AppSettings( + database=DatabaseConfig(url=f"sqlite+aiosqlite:///{tmp_path}/test.db"), + deployment=DeploymentConfig(directory=tmp_path), + ) + + +@pytest.fixture() +async def engine(settings: AppSettings): + return init_engine(settings=settings) + + +@pytest.fixture() +async def session_factory(engine, settings: AppSettings): + import acme_api.db as db_mod + return db_mod._SessionFactory # type: ignore[return-value] + + +@pytest.fixture() +async def db(engine, session_factory) -> AsyncSession: + await init_db(engine) + async with session_factory() as s: + yield s + await s.commit() + + +# --------------------------------------------------------------------------- +# TestCertificateCRUD +# --------------------------------------------------------------------------- + + +class TestCertificateCRUD: + + @pytest.mark.anyio + async def test_create_certificate(self, db: AsyncSession) -> None: + cert = Certificate( + name="example-cert", + domains=["example.com"], + acme_account_ref="letsencrypt-prod", + dns_provider_ref="cloudflare-main", + ) + db.add(cert) + await db.flush() + + row = (await db.execute(select(Certificate).where(Certificate.id == cert.id))).scalar_one() + assert row.name == "example-cert" + assert row.domains == ["example.com"] + assert row.status == CertificateStatus.PENDING + + @pytest.mark.anyio + async def test_query_by_name(self, db: AsyncSession) -> None: + cert = Certificate( + name="my-site-cert", + domains=["my-site.io"], + acme_account_ref="acme-account-1", + dns_provider_ref="dns-provider-1", + ) + db.add(cert) + await db.flush() + + result = (await db.execute(select(Certificate).where(Certificate.name == "my-site-cert"))).scalar_one() + assert result.id == cert.id + assert result.domains == ["my-site.io"] + + @pytest.mark.anyio + async def test_update_status(self, db: AsyncSession) -> None: + cert = Certificate( + name="update-test-cert", + domains=["update.example.com"], + acme_account_ref="acme-account-1", + dns_provider_ref="dns-provider-1", + ) + db.add(cert) + await db.flush() + + assert cert.status == CertificateStatus.PENDING + + cert.status = CertificateStatus.VALID + await db.commit() + await db.refresh(cert) + + assert cert.status == CertificateStatus.VALID + + row = (await db.execute(select(Certificate).where(Certificate.id == cert.id))).scalar_one() + assert row.status == CertificateStatus.VALID + + @pytest.mark.anyio + async def test_delete_certificate(self, db: AsyncSession) -> None: + cert = Certificate( + name="delete-me-cert", + domains=["del.example.com"], + acme_account_ref="acme-account-1", + dns_provider_ref="dns-provider-1", + ) + db.add(cert) + await db.flush() + + cert_id = cert.id + await db.delete(cert) + await db.commit() + + row = (await db.execute(select(Certificate).where(Certificate.id == cert_id))).scalar_one_or_none() + assert row is None + + +# --------------------------------------------------------------------------- +# TestEventCRUD +# --------------------------------------------------------------------------- + + +class TestEventCRUD: + + @pytest.mark.anyio + async def test_create_event(self, db: AsyncSession) -> None: + cert = Certificate( + name="parent-cert", + domains=["parent.example.com"], + acme_account_ref="acme-account-1", + dns_provider_ref="dns-provider-1", + ) + db.add(cert) + await db.flush() + + ev = Event( + event_type="certificate.created", + certificate_id=cert.id, + details={"note": "initial"}, + ) + db.add(ev) + await db.flush() + + row = (await db.execute(select(Event).where(Event.id == ev.id))).scalar_one() + assert row.event_type == "certificate.created" + assert row.certificate_id == cert.id + assert row.details == {"note": "initial"} + + @pytest.mark.anyio + async def test_query_events_by_certificate(self, db: AsyncSession) -> None: + cert = Certificate( + name="multi-event-cert", + domains=["multi.example.com"], + acme_account_ref="acme-account-1", + dns_provider_ref="dns-provider-1", + ) + db.add(cert) + await db.flush() + + ev1 = Event(event_type="certificate.requested", certificate_id=cert.id, details={"step": 1}) + ev2 = Event(event_type="certificate.issued", certificate_id=cert.id, details={"step": 2}) + db.add_all([ev1, ev2]) + await db.flush() + + result = (await db.execute(select(Event).where(Event.certificate_id == cert.id))).scalars().all() + assert len(result) == 2 + + @pytest.mark.anyio + async def test_standalone_event(self, db: AsyncSession) -> None: + ev = Event( + event_type="system.bootstrap", + certificate_id=None, + details={"message": "standalone event"}, + ) + db.add(ev) + await db.flush() + + row = (await db.execute(select(Event).where(Event.id == ev.id))).scalar_one() + assert row.certificate_id is None + assert row.event_type == "system.bootstrap" diff --git a/tests/test_db.py b/tests/test_db.py new file mode 100644 index 0000000..c530e38 --- /dev/null +++ b/tests/test_db.py @@ -0,0 +1,139 @@ +"""Tests for async DB engine, session factory, and DI dependency.""" + +from __future__ import annotations + +import uuid as _uuid +from pathlib import Path + +import pytest +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession + +import acme_api.db as db_mod +from acme_api.config import AppSettings, DatabaseConfig, DeploymentConfig +from acme_api.db import get_db, init_db, init_engine + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +@pytest.fixture() +def settings(tmp_path: Path) -> AppSettings: + return AppSettings( + database=DatabaseConfig(url=f"sqlite+aiosqlite:///{tmp_path}/test.db"), + deployment=DeploymentConfig(directory=tmp_path), + ) + + +# --------------------------------------------------------------------------- +# TestInitEngine +# --------------------------------------------------------------------------- + +class TestInitEngine: + def test_creates_engine_and_session_factory(self, settings: AppSettings) -> None: + engine = init_engine(settings=settings) + + assert isinstance(engine, AsyncEngine) + assert db_mod._engine is not None + assert db_mod._SessionFactory is not None + + def test_pool_size_from_config(self, tmp_path: Path) -> None: + custom_pool_size = 3 + cfg = AppSettings( + database=DatabaseConfig( + url=f"sqlite+aiosqlite:///{tmp_path}/test.db", + pool_size=custom_pool_size, + ), + deployment=DeploymentConfig(directory=tmp_path), + ) + + engine = init_engine(settings=cfg) + + assert engine.pool.size() == custom_pool_size + + +# --------------------------------------------------------------------------- +# TestGetDb +# --------------------------------------------------------------------------- + +class TestGetDb: + @pytest.mark.anyio + async def test_yields_active_session(self, settings: AppSettings) -> None: + """get_db yields an AsyncSession that can execute queries.""" + engine = init_engine(settings=settings) + await init_db(engine=engine) + + async with get_db() as session: + assert isinstance(session, AsyncSession) + result = await session.execute(text("SELECT 1")) + assert result.scalar_one() == 1 + + @pytest.mark.anyio + async def test_commits_on_success(self, settings: AppSettings) -> None: + """A value inserted inside get_db persists and is visible from a new session.""" + engine = init_engine(settings=settings) + await init_db(engine=engine) + + row_id = _uuid.uuid4() + async with get_db() as session: + await session.execute( + text("INSERT INTO events (id, event_type, details) VALUES (:id, 'test.event', '{}')"), + {"id": str(row_id)}, + ) + await session.commit() + + # Open a fresh session to confirm persistence after commit. + async with get_db() as verify: + result = await verify.execute( + text("SELECT event_type FROM events WHERE id = :id"), + {"id": str(row_id)}, + ) + assert result.scalar_one() == "test.event" + + @pytest.mark.anyio + async def test_rollback_on_exception(self, settings: AppSettings) -> None: + """An exception inside get_db rolls back so the insert is lost.""" + engine = init_engine(settings=settings) + await init_db(engine=engine) + + row_id = _uuid.uuid4() + try: + async with get_db() as session: + await session.execute( + text("INSERT INTO events (id, event_type, details) VALUES (:id, 'rollback.event', '{}')"), + {"id": str(row_id)}, + ) + raise ValueError("boom") + except ValueError: + pass + + # Open a fresh session to confirm the row was rolled back. + async with get_db() as verify: + result = await verify.execute( + text("SELECT id FROM events WHERE id = :id"), + {"id": str(row_id)}, + ) + assert result.scalar_one_or_none() is None + + +# --------------------------------------------------------------------------- +# TestInitDb +# --------------------------------------------------------------------------- + +class TestInitDb: + @pytest.mark.anyio + async def test_creates_all_tables(self, settings: AppSettings) -> None: + engine = init_engine(settings=settings) + await init_db(engine=engine) + + async with engine.connect() as conn: + result = await conn.execute( + text("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") + ) + table_names = [row[0] for row in result.fetchall()] + + # The three concrete model tables must all exist. + assert "certificates" in table_names + assert "events" in table_names + assert "renewal_attempts" in table_names diff --git a/tests/test_schemas.py b/tests/test_schemas.py new file mode 100644 index 0000000..a6b56f7 --- /dev/null +++ b/tests/test_schemas.py @@ -0,0 +1,190 @@ +"""Round-trip serialization and validation tests for Pydantic schemas.""" + +from __future__ import annotations + +import uuid +from datetime import datetime, timezone + +import pytest + +from acme_api.schemas.certificate import ( + CertificateCreate, + CertificateRead, + CertificateStatus, +) +from acme_api.schemas.config_readonly import AcmeAccountRead, DnsProviderRead +from acme_api.schemas.event import EventCreate + + +# ── TestCertificateCreate ────────────────────────────────────────────── + +class TestCertificateCreate: + def test_valid_creation(self) -> None: + cert = CertificateCreate( + name="my-cert", + domains=["example.com", "www.example.com"], + acme_account_ref="letsencrypt-prod", + dns_provider_ref="cloudflare", + key_algorithm="ecdsa", + ) + assert cert.name == "my-cert" + assert cert.domains == ["example.com", "www.example.com"] + assert cert.acme_account_ref == "letsencrypt-prod" + assert cert.dns_provider_ref == "cloudflare" + assert cert.key_algorithm == "ecdsa" + + def test_default_key_algorithm(self) -> None: + """Default key_algorithm should be 'ecdsa' when omitted.""" + cert = CertificateCreate( + name="my-cert", + domains=["example.com"], + acme_account_ref="acme-acc", + dns_provider_ref="dns-prov", + ) + assert cert.key_algorithm == "ecdsa" + + def test_rsa_key_algorithms(self) -> None: + """Both rsa-2048 and rsa-4096 should be accepted.""" + for algo in ("rsa-2048", "rsa-4096"): + cert = CertificateCreate( + name="my-cert", + domains=["example.com"], + acme_account_ref="acme-acc", + dns_provider_ref="dns-prov", + key_algorithm=algo, + ) + assert cert.key_algorithm == algo + + def test_domain_validation_rejects_bare_string(self) -> None: + """A domain without a dot (e.g. 'notadomain') should fail validation.""" + with pytest.raises(ValueError, match=r"does not match a valid DNS label"): + CertificateCreate( + name="my-cert", + domains=["notadomain"], + acme_account_ref="acme-acc", + dns_provider_ref="dns-prov", + ) + + def test_domain_validation_accepts_standard(self) -> None: + """A standard FQDN like 'example.com' should pass validation.""" + cert = CertificateCreate( + name="my-cert", + domains=["example.com"], + acme_account_ref="acme-acc", + dns_provider_ref="dns-prov", + ) + assert "example.com" in cert.domains + + def test_domain_validation_accepts_wildcard(self) -> None: + """A wildcard domain like '*.example.com' should pass validation.""" + cert = CertificateCreate( + name="my-cert", + domains=["*.example.com"], + acme_account_ref="acme-acc", + dns_provider_ref="dns-prov", + ) + assert "*.example.com" in cert.domains + + def test_empty_domains_raises(self) -> None: + """An empty domains list should raise (min_length=1).""" + with pytest.raises(ValueError): + CertificateCreate( + name="my-cert", + domains=[], + acme_account_ref="acme-acc", + dns_provider_ref="dns-prov", + ) + + +# ── TestCertificateRead ──────────────────────────────────────────────── + +class TestCertificateRead: + def test_from_attributes_roundtrip(self) -> None: + """Build a dict of attributes and construct CertificateRead via model_validate.""" + now = datetime.now(timezone.utc) + data = { + "id": uuid.uuid4(), + "name": "round-trip-cert", + "domains": ["example.com", "www.example.com"], + "acme_account_ref": "letsencrypt-prod", + "dns_provider_ref": "cloudflare", + "key_algorithm": "ecdsa", + "expiry_date": now.replace(year=now.year + 1), + "status": CertificateStatus.PENDING, + "created_at": now, + "updated_at": now, + } + + cert = CertificateRead.model_validate(data) + + # All fields round-trip faithfully. + assert cert.id == data["id"] + assert cert.name == data["name"] + assert cert.domains == data["domains"] + assert cert.acme_account_ref == data["acme_account_ref"] + assert cert.dns_provider_ref == data["dns_provider_ref"] + assert cert.key_algorithm == data["key_algorithm"] + assert cert.expiry_date == data["expiry_date"] + assert cert.status == CertificateStatus.PENDING + assert cert.created_at == now + assert cert.updated_at == now + + # Serialize back to dict and verify no drift. + serialized = cert.model_dump() + assert serialized["name"] == "round-trip-cert" + assert serialized["domains"] == ["example.com", "www.example.com"] + assert serialized["status"] == CertificateStatus.PENDING + + +# ── TestEventCreate ──────────────────────────────────────────────────── + +class TestEventCreate: + def test_valid_creation(self) -> None: + evt = EventCreate( + event_type="certificate.created", + certificate_id=uuid.uuid4(), + details={"foo": "bar"}, + ) + assert evt.event_type == "certificate.created" + assert evt.details == {"foo": "bar"} + + def test_default_details(self) -> None: + """Details should default to an empty dict when omitted.""" + evt = EventCreate(event_type="test.event") + assert evt.details == {} + + +# ── TestAcmeAccountRead ──────────────────────────────────────────────── + +class TestAcmeAccountRead: + def test_valid_creation(self) -> None: + acct = AcmeAccountRead( + name="letsencrypt-production", + server_url="https://acme.v02.api.letsencrypt.org/directory", + ) + assert acct.name == "letsencrypt-production" + assert acct.server_url == "https://acme.v02.api.letsencrypt.org/directory" + + # Round-trip via model_dump / model_validate. + data = acct.model_dump() + round_tripped = AcmeAccountRead(**data) + assert round_tripped.name == acct.name + assert round_tripped.server_url == acct.server_url + + +# ── TestDnsProviderRead ──────────────────────────────────────────────── + +class TestDnsProviderRead: + def test_valid_creation(self) -> None: + prov = DnsProviderRead( + name="production", + provider_name="cloudflare", + ) + assert prov.name == "production" + assert prov.provider_name == "cloudflare" + + # Round-trip via model_dump / model_validate. + data = prov.model_dump() + round_tripped = DnsProviderRead(**data) + assert round_tripped.name == prov.name + assert round_tripped.provider_name == prov.provider_name From 6de897b66efe1127889ae8546ee0cd14c497c6e5 Mon Sep 17 00:00:00 2001 From: Streaky Date: Thu, 2 Jul 2026 01:18:55 +0100 Subject: [PATCH 10/26] post phase 2 cleanup --- acme_api/config.py | 8 ++++---- acme_api/db.py | 29 ++++++++++++++++++----------- acme_api/models/base.py | 8 ++++---- acme_api/models/certificate.py | 2 +- acme_api/models/event.py | 4 ++-- acme_api/models/renewal_attempt.py | 4 ++-- acme_api/schemas/certificate.py | 3 +++ acme_api/schemas/config_readonly.py | 2 ++ acme_api/schemas/event.py | 2 ++ alembic/env.py | 2 +- tests/test_alembic.py | 3 +-- tests/test_crud.py | 20 +++++++++++--------- tests/test_db.py | 12 ++++++------ tests/test_schemas.py | 2 +- 14 files changed, 58 insertions(+), 43 deletions(-) diff --git a/acme_api/config.py b/acme_api/config.py index 9522724..bf894cf 100644 --- a/acme_api/config.py +++ b/acme_api/config.py @@ -123,11 +123,11 @@ def check(self) -> None: """ errors = [] - # Database URL must look like a valid DSN - if not self.database.url.startswith(("sqlite", "postgresql")): + # v1 is SQLite-backed. Keep validation narrow until another database + # backend is intentionally supported end to end. + if not self.database.url.startswith("sqlite"): errors.append( - f"database.url must start with 'sqlite' or 'postgresql', " - f"got: {self.database.url!r}" + f"database.url must start with 'sqlite', got: {self.database.url!r}" ) # Deployment directory should exist. Creation is handled separately so diff --git a/acme_api/db.py b/acme_api/db.py index 18ead7a..2c4a0f9 100644 --- a/acme_api/db.py +++ b/acme_api/db.py @@ -16,14 +16,15 @@ from acme_api.config import AppSettings from acme_api.models.base import Base - # --------------------------------------------------------------------------- # Module-level handles populated by init_engine() # --------------------------------------------------------------------------- -_SessionFactory: typing.Optional[async_sessionmaker[AsyncSession]] = None -_engine: typing.Optional[AsyncEngine] = None +_SESSION_FACTORY: typing.Optional[async_sessionmaker[AsyncSession]] = None +_ENGINE: typing.Optional[AsyncEngine] = None + + def _set_sqlite_pragma( - dbapi_conn: typing.Any, connection_record: typing.Any + dbapi_conn: typing.Any, _connection_record: typing.Any ) -> None: """Apply SQLite pragmas on each new aiosqlite connection.""" cursor = dbapi_conn.cursor() @@ -48,7 +49,7 @@ def init_engine(settings: AppSettings) -> AsyncEngine: ------- AsyncEngine configured with SQLite pragmas and pool settings. """ - global _engine, _SessionFactory # noqa: PLW0603 + global _ENGINE, _SESSION_FACTORY # pylint: disable=global-statement engine = create_async_engine( url=settings.database.url, @@ -59,11 +60,11 @@ def init_engine(settings: AppSettings) -> AsyncEngine: pool_pre_ping=True, ) - # Apply pragmas on each new connection. - sa_event.listen(engine.sync_engine, "connect", _set_sqlite_pragma) + if engine.url.get_backend_name() == "sqlite": + sa_event.listen(engine.sync_engine, "connect", _set_sqlite_pragma) - _engine = engine - _SessionFactory = async_sessionmaker( + _ENGINE = engine + _SESSION_FACTORY = async_sessionmaker( bind=engine, class_=AsyncSession, expire_on_commit=False, @@ -75,11 +76,17 @@ def init_engine(settings: AppSettings) -> AsyncEngine: @contextlib.asynccontextmanager async def get_db() -> typing.AsyncIterator[AsyncSession]: """FastAPI dependency that yields an async database session.""" - assert _SessionFactory is not None, "Call init_engine() before mounting routes." - async with _SessionFactory() as session: + session_factory = get_session_factory() + async with session_factory() as session: yield session +def get_session_factory() -> async_sessionmaker[AsyncSession]: + """Return the initialized async session factory.""" + assert _SESSION_FACTORY is not None, "Call init_engine() before mounting routes." + return _SESSION_FACTORY + + async def init_db(engine: AsyncEngine) -> None: """Create all tables defined by the Base metadata. diff --git a/acme_api/models/base.py b/acme_api/models/base.py index 14910be..c22bb2d 100644 --- a/acme_api/models/base.py +++ b/acme_api/models/base.py @@ -4,7 +4,7 @@ import datetime as _dt -from sqlalchemy import DateTime, func +from sqlalchemy import DateTime, text from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column @@ -26,14 +26,14 @@ class TimestampMixin: created_at: Mapped[_dt.datetime] = mapped_column( DateTime(timezone=True), - server_default=func.now(), + server_default=text("CURRENT_TIMESTAMP"), nullable=False, doc="Timestamp when the row was first inserted.", ) updated_at: Mapped[_dt.datetime] = mapped_column( DateTime(timezone=True), - onupdate=func.now(), - server_default=func.now(), + onupdate=text("CURRENT_TIMESTAMP"), + server_default=text("CURRENT_TIMESTAMP"), nullable=False, doc="Timestamp of the most recent update to this row.", ) diff --git a/acme_api/models/certificate.py b/acme_api/models/certificate.py index b613f76..bff4542 100644 --- a/acme_api/models/certificate.py +++ b/acme_api/models/certificate.py @@ -6,7 +6,7 @@ import enum import uuid as _uuid -from sqlalchemy import DateTime, Enum, JSON, String +from sqlalchemy import JSON, DateTime, Enum, String from sqlalchemy.orm import Mapped, mapped_column, relationship from acme_api.models.base import Base, TimestampMixin diff --git a/acme_api/models/event.py b/acme_api/models/event.py index 51d355d..772d1f2 100644 --- a/acme_api/models/event.py +++ b/acme_api/models/event.py @@ -5,7 +5,7 @@ import datetime as _dt import uuid as _uuid -from sqlalchemy import DateTime, ForeignKey, JSON, String, func +from sqlalchemy import JSON, DateTime, ForeignKey, String, text from sqlalchemy.orm import Mapped, mapped_column, relationship from acme_api.models.base import Base @@ -33,7 +33,7 @@ class Event(Base): timestamp: Mapped[_dt.datetime] = mapped_column( DateTime(timezone=True), - server_default=func.now(), + server_default=text("CURRENT_TIMESTAMP"), nullable=False, doc="UTC time at which this event occurred.", ) diff --git a/acme_api/models/renewal_attempt.py b/acme_api/models/renewal_attempt.py index a8f169d..fa55402 100644 --- a/acme_api/models/renewal_attempt.py +++ b/acme_api/models/renewal_attempt.py @@ -5,7 +5,7 @@ import datetime as _dt import uuid as _uuid -from sqlalchemy import DateTime, ForeignKey, JSON, String, func +from sqlalchemy import JSON, DateTime, ForeignKey, String, text from sqlalchemy.orm import Mapped, mapped_column, relationship from acme_api.models.base import Base @@ -41,7 +41,7 @@ class RenewalAttempt(Base): attempted_at: Mapped[_dt.datetime] = mapped_column( DateTime(timezone=True), - server_default=func.now(), + server_default=text("CURRENT_TIMESTAMP"), nullable=False, doc="UTC time the renewal attempt was initiated.", ) diff --git a/acme_api/schemas/certificate.py b/acme_api/schemas/certificate.py index 819c780..286c74e 100644 --- a/acme_api/schemas/certificate.py +++ b/acme_api/schemas/certificate.py @@ -1,3 +1,5 @@ +"""Certificate request and response schemas.""" + from __future__ import annotations import re @@ -8,6 +10,7 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator + class CertificateStatus(StrEnum): """Lifecycle states a certificate can occupy.""" diff --git a/acme_api/schemas/config_readonly.py b/acme_api/schemas/config_readonly.py index 138871f..45e1b88 100644 --- a/acme_api/schemas/config_readonly.py +++ b/acme_api/schemas/config_readonly.py @@ -1,3 +1,5 @@ +"""Read-only schemas for config-owned integrations.""" + from __future__ import annotations from pydantic import BaseModel diff --git a/acme_api/schemas/event.py b/acme_api/schemas/event.py index 743f7ed..7fb6d2b 100644 --- a/acme_api/schemas/event.py +++ b/acme_api/schemas/event.py @@ -1,3 +1,5 @@ +"""Event audit-log request and response schemas.""" + from __future__ import annotations import uuid diff --git a/alembic/env.py b/alembic/env.py index 6090bb7..9fa73db 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -40,7 +40,7 @@ # Model metadata (autogenerate support) # --------------------------------------------------------------------------- -from acme_api.models.base import Base # noqa: E402 +from acme_api.models import Base # noqa: E402 target_metadata = Base.metadata diff --git a/tests/test_alembic.py b/tests/test_alembic.py index 561898e..392b7fe 100644 --- a/tests/test_alembic.py +++ b/tests/test_alembic.py @@ -6,10 +6,8 @@ import subprocess from pathlib import Path -import pytest from sqlalchemy import create_engine, text - PROJECT_ROOT = Path(__file__).resolve().parent.parent @@ -74,6 +72,7 @@ class = StreamHandler cwd=PROJECT_ROOT, env=env, text=True, + check=False, ) assert result.returncode == 0, ( f"alembic upgrade head failed:\n{result.stdout}\n{result.stderr}" diff --git a/tests/test_crud.py b/tests/test_crud.py index 10dc3e4..12831d6 100644 --- a/tests/test_crud.py +++ b/tests/test_crud.py @@ -2,19 +2,18 @@ from __future__ import annotations -import uuid as _uuid +from collections.abc import AsyncGenerator from pathlib import Path import pytest from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker from acme_api.config import AppSettings, DatabaseConfig, DeploymentConfig -from acme_api.db import init_db, init_engine +from acme_api.db import get_session_factory, init_db, init_engine from acme_api.models.certificate import Certificate, CertificateStatus from acme_api.models.event import Event - # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @@ -29,18 +28,21 @@ def settings(tmp_path: Path) -> AppSettings: @pytest.fixture() -async def engine(settings: AppSettings): +def engine(settings: AppSettings) -> AsyncEngine: return init_engine(settings=settings) @pytest.fixture() -async def session_factory(engine, settings: AppSettings): - import acme_api.db as db_mod - return db_mod._SessionFactory # type: ignore[return-value] +def session_factory(engine: AsyncEngine) -> async_sessionmaker[AsyncSession]: + assert engine is not None + return get_session_factory() @pytest.fixture() -async def db(engine, session_factory) -> AsyncSession: +async def db( + engine: AsyncEngine, + session_factory: async_sessionmaker[AsyncSession], +) -> AsyncGenerator[AsyncSession, None]: await init_db(engine) async with session_factory() as s: yield s diff --git a/tests/test_db.py b/tests/test_db.py index c530e38..831c339 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -4,20 +4,20 @@ import uuid as _uuid from pathlib import Path +from typing import Callable, cast import pytest from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession -import acme_api.db as db_mod from acme_api.config import AppSettings, DatabaseConfig, DeploymentConfig -from acme_api.db import get_db, init_db, init_engine - +from acme_api.db import get_db, get_session_factory, init_db, init_engine # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- + @pytest.fixture() def settings(tmp_path: Path) -> AppSettings: return AppSettings( @@ -35,8 +35,7 @@ def test_creates_engine_and_session_factory(self, settings: AppSettings) -> None engine = init_engine(settings=settings) assert isinstance(engine, AsyncEngine) - assert db_mod._engine is not None - assert db_mod._SessionFactory is not None + assert callable(get_session_factory()) def test_pool_size_from_config(self, tmp_path: Path) -> None: custom_pool_size = 3 @@ -50,7 +49,8 @@ def test_pool_size_from_config(self, tmp_path: Path) -> None: engine = init_engine(settings=cfg) - assert engine.pool.size() == custom_pool_size + pool_size = cast(Callable[[], int], getattr(engine.pool, "size")) + assert pool_size() == custom_pool_size # --------------------------------------------------------------------------- diff --git a/tests/test_schemas.py b/tests/test_schemas.py index a6b56f7..ccc4145 100644 --- a/tests/test_schemas.py +++ b/tests/test_schemas.py @@ -15,9 +15,9 @@ from acme_api.schemas.config_readonly import AcmeAccountRead, DnsProviderRead from acme_api.schemas.event import EventCreate - # ── TestCertificateCreate ────────────────────────────────────────────── + class TestCertificateCreate: def test_valid_creation(self) -> None: cert = CertificateCreate( From e0d8af24badb382e9b122bfcf613f403eb1112f2 Mon Sep 17 00:00:00 2001 From: Streaky Date: Thu, 2 Jul 2026 02:08:04 +0100 Subject: [PATCH 11/26] phase 3 --- acme_api/backend/__init__.py | 22 ++ acme_api/backend/acmesh_backend.py | 405 +++++++++++++++++++++++++++++ acme_api/backend/dataclasses.py | 57 ++++ acme_api/backend/mock_backend.py | 71 +++++ acme_api/backend/protocol.py | 41 +++ tests/test_backend_commands.py | 302 +++++++++++++++++++++ 6 files changed, 898 insertions(+) create mode 100644 acme_api/backend/__init__.py create mode 100644 acme_api/backend/acmesh_backend.py create mode 100644 acme_api/backend/dataclasses.py create mode 100644 acme_api/backend/mock_backend.py create mode 100644 acme_api/backend/protocol.py create mode 100644 tests/test_backend_commands.py diff --git a/acme_api/backend/__init__.py b/acme_api/backend/__init__.py new file mode 100644 index 0000000..4f0c12e --- /dev/null +++ b/acme_api/backend/__init__.py @@ -0,0 +1,22 @@ +"""ACME backend abstraction package.""" + +from acme_api.backend.acmesh_backend import ( + AcmeShBackend, + TerminalAcmeShError, + TransientAcmeShError, +) +from acme_api.backend.dataclasses import AccountInfo, CertExpiry, IssuanceResult +from acme_api.backend.mock_backend import MockAcmeBackend +from acme_api.backend.protocol import AcmeBackend, ChallengeMethod + +__all__ = [ + "AcmeBackend", + "AcmeShBackend", + "AccountInfo", + "ChallengeMethod", + "CertExpiry", + "IssuanceResult", + "MockAcmeBackend", + "TerminalAcmeShError", + "TransientAcmeShError", +] diff --git a/acme_api/backend/acmesh_backend.py b/acme_api/backend/acmesh_backend.py new file mode 100644 index 0000000..7a0838b --- /dev/null +++ b/acme_api/backend/acmesh_backend.py @@ -0,0 +1,405 @@ +"""acme.sh subprocess wrapper — concrete AcmeBackend implementation.""" + +from __future__ import annotations + +import asyncio +import dataclasses as _dc +import datetime as _dt +import logging +import os +import pathlib +import re +import shlex +import typing as t +from functools import cached_property + +from acme_api.backend.dataclasses import AccountInfo, CertExpiry, IssuanceResult +from acme_api.backend.protocol import AcmeBackend, ChallengeMethod + +logger = logging.getLogger(__name__) + + +_PATH_PATTERNS = { + "cert": ( + re.compile(r"Your cert is in\s+(?P[^,\n]+)", re.IGNORECASE), + re.compile(r"CertPath=(?P[^\n]+)", re.IGNORECASE), + ), + "key": ( + re.compile(r"Your cert key is in\s+(?P[^,\n]+)", re.IGNORECASE), + re.compile(r"KeyPath=(?P[^\n]+)", re.IGNORECASE), + ), + "chain": ( + re.compile( + r"(?:CA certificates|intermediate CA cert)\s+(?:are|is)\s+in\s+(?P[^,\n]+)", + re.IGNORECASE, + ), + re.compile(r"CAPath=(?P[^\n]+)", re.IGNORECASE), + ), + "fullchain": ( + re.compile(r"full chain certs is there:\s+(?P[^\n]+)", re.IGNORECASE), + re.compile(r"FullChainPath=(?P[^\n]+)", re.IGNORECASE), + ), +} +_DATE_PATTERNS = ( + re.compile(r"\*{3}\s*Expired at:\s*(?P[^\n]+)", re.IGNORECASE), + re.compile(r"Le_NextRenewTimeStr=['\"]?(?P[^'\"\n]+)", re.IGNORECASE), + re.compile(r"Not After\s*:\s*(?P[^\n]+)", re.IGNORECASE), +) + + +class AcmeShError(Exception): + """Base exception for acme.sh errors. + + Subclasses distinguish transient failures (DNS propagation, rate limits) from terminal + ones (account invalid, misconfiguration). The API layer maps these to HTTP statuses. + """ + + +class TerminalAcmeShError(AcmeShError): + """An error that will not resolve by retrying the same operation.""" + + def __init__(self, message: str, *, stderr: str = "") -> None: + super().__init__(message) + self.stderr = stderr + + +class TransientAcmeShError(AcmeShError): + """An error that may resolve on retry (DNS propagation, transient CA outage).""" + + def __init__(self, message: str, *, stderr: str = "") -> None: + super().__init__(message) + self.stderr = stderr + + +@_dc.dataclass(frozen=True) +class _AcmeShBackendConfig: + """Internal runtime configuration for AcmeShBackend.""" + + binary_path: pathlib.Path + home_dir: pathlib.Path + log_file: pathlib.Path | None + force_renewal: bool + dnssleep_seconds: int | None + + +class AcmeShBackend(AcmeBackend): + """Wraps the ``acme.sh`` CLI as an async backend. + + All subprocess calls run via :mod:`asyncio.subprocess` to avoid blocking the event loop. + The binary path is resolved from config; the home directory holds account state, DNS + records (in persist mode), and deployed certificates. + """ + + def __init__(self, config: _AcmeShBackendConfig) -> None: + self._cfg = config + # Lazy — resolved on first call to _ensure_binary so import-time does not fail when + # acme.sh is not yet installed (e.g., in a fresh test container). + self._binary_resolved: bool = False + + @cached_property + def binary_path(self) -> pathlib.Path: + """Resolve the acme.sh binary path; prefer ``acme.sh`` on PATH if set.""" + return self._cfg.binary_path + + async def _run( + self, + args: list[str], + *, + env: dict[str, str] | None = None, + expected_exitcode: int = 0, + capture_stderr: bool = True, + ) -> tuple[int, str]: + """Execute an acme.sh command and return (exit_code, combined_output). + + Raises :class:`TerminalAcmeShError` or :class:`TransientAcmeShError` when the + subprocess exits non-zero — classification happens in :meth:`_classify_exit`. + """ + cmd = [str(self.binary_path), "--home", str(self._cfg.home_dir)] + args + + env = {**os.environ, **(env or {})} + if self._cfg.log_file is not None: + env["LOG_FILE"] = str(self._cfg.log_file) + + logger.info("Running acme.sh command: %s", " ".join(cmd)) + + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE if capture_stderr else asyncio.subprocess.DEVNULL, + env=env, + ) + + stdout_bytes, stderr_bytes = await proc.communicate() + stdout_text = stdout_bytes.decode(errors="replace") if stdout_bytes else "" + stderr_text = stderr_bytes.decode(errors="replace") if stderr_bytes else "" + + combined = stdout_text + ("\n" + stderr_text if capture_stderr and stderr_text else "") + exit_code = proc.returncode or 0 + + logger.info( + "acme.sh exited with code %d (stdout=%d chars, stderr=%d chars)", + exit_code, + len(stdout_text), + len(stderr_text), + ) + + if exit_code != expected_exitcode: + raise self._classify_exit(exit_code, stdout_text, stderr_text) + + return exit_code, combined + + # -- backend interface ------------------------------------------------------------------ + + async def register_account( + self, + email: str, + server_url: str, + ) -> AccountInfo: + args = [ + "--register", + f"--email={email}", + f"--server={server_url}", + "--nocaptcha", + "--accountkey-file", + str(self._cfg.home_dir / "acct.key"), + ] + await self._run(args, capture_stderr=False) + + key_path = self._cfg.home_dir / "acct.key" + if not key_path.is_file(): + raise TerminalAcmeShError( + f"acme.sh registered account but key file missing at {key_path}", + ) + return AccountInfo( + key_path=str(key_path), + email=email, + server_url=server_url, + ) + + async def issue_certificate( + self, + domains: list[str], + method: ChallengeMethod, + challenge_params: dict[str, t.Any], + account_key_path: str | None = None, + ) -> IssuanceResult: + if not domains: + raise TerminalAcmeShError("At least one domain is required for issuance") + + primary_domain = domains[0] + command_env: dict[str, str] = {} + + args = [ + "--issue", + f"--domain={primary_domain}", + *chain_args_for(domains), + ] + + if method == "dns-01": + dns_provider = str(challenge_params["dns_provider"]) + env_vars_file = challenge_params.get("env_vars_file") + args += [f"--dns={dns_provider}"] + if self._cfg.dnssleep_seconds is not None: + args += ["--dnssleep", str(self._cfg.dnssleep_seconds)] + if env_vars_file is not None: + command_env.update(_load_env_vars(pathlib.Path(str(env_vars_file)))) + elif method == "webroot": + webroot = str(challenge_params["webroot_dir"]) + args += [f"--webroot={webroot}"] + + if account_key_path is not None: + args += [f"--accountkey-file={account_key_path}"] + + _, stdout = await self._run(args, env=command_env) + + cert_info = parse_cert_expiry(stdout) + return IssuanceResult( + account_key_path=account_key_path or str(self._cfg.home_dir / "acct.key"), + cert=cert_info, + domains=domains, + ) + + async def renew_certificate( + self, + domains: list[str], + force_renewal: bool = False, + ) -> IssuanceResult: + if not domains: + raise TerminalAcmeShError("At least one domain is required for renewal") + + args = ["--renew", f"--domain={domains[0]}", *chain_args_for(domains)] + if force_renewal or self._cfg.force_renewal: + args.append("--force") + + _, stdout = await self._run(args) + + cert_info = parse_cert_expiry(stdout) + return IssuanceResult( + account_key_path=str(self._cfg.home_dir / "acct.key"), + cert=cert_info, + domains=domains, + ) + + async def get_certificate_expiry(self, cert_path: str) -> CertExpiry: + args = [ + "--in", + cert_path, + "--info", + ] + _, stdout = await self._run(args) + return parse_cert_expiry(stdout) + + # -- internals ------------------------------------------------------------------------ + + def _classify_exit( + self, + exit_code: int, + stdout: str, + stderr: str, + ) -> AcmeShError: + """Map acme.sh output to a typed error. + + Terminal vs transient classification follows the patterns documented at + https://github.com/acmesh-official/acme.sh/wiki/ErrorCodes. Transient errors include + DNS propagation waits, rate limits (429), and temporary CA unavailability. + """ + combined = f"{stdout}\n{stderr}".lower() + + if any(token in combined for token in ("dns validation failed", "txt record not found", "timeout")): + return TransientAcmeShError( + "DNS propagation may still be in progress; retry with longer dnssleep", + stderr=stderr, + ) + + if any(token in combined for token in ("rate limit reached", "429", "too many requests")): + return TransientAcmeShError("ACME rate limit hit", stderr=stderr) + + if any(token in combined for token in ("account key invalid", "server returned 403", "unauthorized")): + return TerminalAcmeShError( + "Account is invalid or unauthorized — re-register required", + stderr=stderr, + ) + + if any(token in combined for token in ("domain not match", "misconfiguration", "invalid domain")): + return TerminalAcmeShError("Configuration error: invalid domains or parameters", stderr=stderr) + + if exit_code == 75: + # acme.sh uses 75 for general transient failures + return TransientAcmeShError( + f"acme.sh exited with transient code {exit_code}", + stderr=stderr, + ) + + return TerminalAcmeShError( + f"acme.sh exited non-zero: code={exit_code}\n{stderr[:500]}", + stderr=stderr, + ) + + +# -- module-level helpers ------------------------------------------------------------------- + + +def chain_args_for(domains: list[str]) -> list[str]: + """Return the ``--domain`` arguments for a SAN cert (excluding the primary). + + acme.sh takes the first ``--domain`` as primary; additional SANs come after. + """ + if len(domains) <= 1: + return [] + return [f"--domain={d}" for d in domains[1:]] + + +def _load_env_vars(env_vars_file: pathlib.Path) -> dict[str, str]: + """Load a shell-style ``export VAR=value`` file into a plain dict. + + Used to inject DNS provider credentials (e.g., ``CLOUDFLARE_EMAIL``, + ``CLOUDFLARE_API_KEY``) before invoking acme.sh — it reads them from the env. + """ + result: dict[str, str] = {} + if not env_vars_file.is_file(): + return result + + for raw_line in env_vars_file.read_text().splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + # Strip the leading ``export `` if present. + if line.startswith("export "): + line = line[len("export ") :] + key, separator, value = line.partition("=") + if separator: + result[key.strip()] = shlex.split(value.strip())[0] if value.strip() else "" + return result + + +def parse_cert_expiry(output: str) -> CertExpiry: + """Extract the expiry record from acme.sh output. + + Raises :class:`TerminalAcmeShError` when no match is found — this means acme.sh failed + silently or returned unexpected content; the caller should inspect stderr. + """ + cert_path = _extract_path(output, "cert") + privkey_path = _extract_path(output, "key") + chain_path = _extract_path(output, "chain") + fullchain_path = _extract_path(output, "fullchain") + expires_str = _extract_expiry_date(output) + + if cert_path is None or privkey_path is None or chain_path is None or expires_str is None: + raise TerminalAcmeShError( + "Could not parse cert expiry from acme.sh output", + stderr=output, + ) + + if fullchain_path is None: + fullchain_path = str(pathlib.Path(cert_path).parent / "fullchain.pem") + + expires_at = _parse_acmesh_datetime(expires_str) + + return CertExpiry( + cert_path=cert_path, + privkey_path=privkey_path, + chain_path=chain_path, + fullchain_path=fullchain_path, + expires_at=expires_at, + ) + + +def _extract_path(output: str, key: str) -> str | None: + """Extract and normalize a path field from acme.sh output.""" + for pattern in _PATH_PATTERNS[key]: + match = pattern.search(output) + if match: + return match.group("path").strip().strip("'\"") + return None + + +def _extract_expiry_date(output: str) -> str | None: + """Extract an expiry or next-renewal date from acme.sh output.""" + for pattern in _DATE_PATTERNS: + match = pattern.search(output) + if match: + return match.group("date").strip() + return None + + +def _parse_acmesh_datetime(s: str) -> _dt.datetime: + """Parse acme.sh's ``YYYY-MM-DD HH:MM:SS+ZZZZ`` format. + + acme.sh always emits UTC offsets; we normalize to a timezone-aware datetime. + """ + # Try ISO-like with offset first, then fall back to plain format. + try: + return _dt.datetime.fromisoformat(s.replace(" UTC", "+00:00").replace(" ", "T")) + except ValueError: + pass + + for fmt in ("%Y-%m-%d %H:%M:%S%z", "%b %d %H:%M:%S %Y %Z"): + try: + parsed = _dt.datetime.strptime(s, fmt) + if parsed.tzinfo is None: + return parsed.replace(tzinfo=_dt.timezone.utc) + return parsed + except ValueError: + continue + + raise TerminalAcmeShError(f"Could not parse acme.sh datetime: {s}") diff --git a/acme_api/backend/dataclasses.py b/acme_api/backend/dataclasses.py new file mode 100644 index 0000000..7317fe8 --- /dev/null +++ b/acme_api/backend/dataclasses.py @@ -0,0 +1,57 @@ +"""Domain-model-agnostic return types for AcmeBackend Protocol.""" + +from __future__ import annotations + +import dataclasses as _dc +import datetime as _dt + + +@_dc.dataclass(frozen=True) +class AccountInfo: + """Registration info returned from acme.sh account operations. + + Attributes: + key_path: Path to the ACME account private key on disk. + email: Registered contact email address. + server_url: The ACME CA endpoint used for registration. + """ + + key_path: str + email: str + server_url: str + + +@_dc.dataclass(frozen=True) +class CertExpiry: + """Parsed expiry information from a certificate or acme.sh --renew output. + + Attributes: + cert_path: Absolute path to the deployed PEM file (e.g., /acmesh/cert.pem). + privkey_path: Absolute path to the private key PEM. + chain_path: Absolute path to the CA chain PEM. + fullchain_path: Absolute path to the concatenated cert+chain PEM. + expires_at: UTC datetime when the certificate expires. + """ + + cert_path: str + privkey_path: str + chain_path: str + fullchain_path: str + expires_at: _dt.datetime + + +@_dc.dataclass(frozen=True) +class IssuanceResult: + """Result of a successful certificate issuance/renewal operation. + + Attributes: + account_key_path: Path to the acme.sh account private key used. + cert: Parsed expiry and file layout information for the new certificate. + domains: List of SANs that were issued (including primary). + message: Human-readable summary or debug hint if non-empty. + """ + + account_key_path: str + cert: CertExpiry + domains: list[str] + message: str = "" diff --git a/acme_api/backend/mock_backend.py b/acme_api/backend/mock_backend.py new file mode 100644 index 0000000..d5640a6 --- /dev/null +++ b/acme_api/backend/mock_backend.py @@ -0,0 +1,71 @@ +"""In-memory ACME backend for API and service tests.""" + +from __future__ import annotations + +import datetime as dt +from pathlib import Path + +from acme_api.backend.dataclasses import AccountInfo, CertExpiry, IssuanceResult +from acme_api.backend.protocol import ChallengeMethod + + +class MockAcmeBackend: + """Deterministic backend that does not shell out to acme.sh.""" + + def __init__(self, base_dir: Path) -> None: + self.base_dir = base_dir + + async def register_account(self, email: str, server_url: str) -> AccountInfo: + """Return a predictable account record.""" + key_path = self.base_dir / "acct.key" + return AccountInfo(key_path=str(key_path), email=email, server_url=server_url) + + async def issue_certificate( + self, + domains: list[str], + method: ChallengeMethod, + challenge_params: dict[str, object], + account_key_path: str | None = None, + ) -> IssuanceResult: + """Return a predictable issuance result.""" + del method, challenge_params + return IssuanceResult( + account_key_path=account_key_path or str(self.base_dir / "acct.key"), + cert=self._cert_expiry(domains[0]), + domains=domains, + ) + + async def renew_certificate( + self, + domains: list[str], + force_renewal: bool = False, + ) -> IssuanceResult: + """Return a predictable renewal result.""" + del force_renewal + return IssuanceResult( + account_key_path=str(self.base_dir / "acct.key"), + cert=self._cert_expiry(domains[0]), + domains=domains, + ) + + async def get_certificate_expiry(self, cert_path: str) -> CertExpiry: + """Return a predictable expiry for the supplied certificate path.""" + cert = self._cert_expiry("example.com") + return CertExpiry( + cert_path=cert_path, + privkey_path=cert.privkey_path, + chain_path=cert.chain_path, + fullchain_path=cert.fullchain_path, + expires_at=cert.expires_at, + ) + + def _cert_expiry(self, domain: str) -> CertExpiry: + safe_domain = domain.replace("*.", "wildcard.") + cert_dir = self.base_dir / safe_domain + return CertExpiry( + cert_path=str(cert_dir / "cert.pem"), + privkey_path=str(cert_dir / "privkey.pem"), + chain_path=str(cert_dir / "chain.pem"), + fullchain_path=str(cert_dir / "fullchain.pem"), + expires_at=dt.datetime.now(dt.timezone.utc) + dt.timedelta(days=90), + ) diff --git a/acme_api/backend/protocol.py b/acme_api/backend/protocol.py new file mode 100644 index 0000000..d511a49 --- /dev/null +++ b/acme_api/backend/protocol.py @@ -0,0 +1,41 @@ +"""AcmeBackend Protocol — domain-model-agnostic interface for ACME CA operations.""" + +from __future__ import annotations + +import typing as t + +if t.TYPE_CHECKING: + from acme_api.backend.dataclasses import AccountInfo, CertExpiry, IssuanceResult + + +ChallengeMethod = t.Literal["dns-01", "webroot"] + + +class AcmeBackend(t.Protocol): + """Interface a concrete ACME backend must implement. + + Return types are intentionally domain-model-agnostic (dataclasses) so the API + layer can map them to SQLAlchemy models without importing from this package. + """ + + async def register_account( + self, + email: str, + server_url: str, + ) -> AccountInfo: ... + + async def issue_certificate( + self, + domains: list[str], + method: ChallengeMethod, + challenge_params: dict[str, t.Any], + account_key_path: str | None = None, + ) -> IssuanceResult: ... + + async def renew_certificate( + self, + domains: list[str], + force_renewal: bool = False, + ) -> IssuanceResult: ... + + async def get_certificate_expiry(self, cert_path: str) -> CertExpiry: ... diff --git a/tests/test_backend_commands.py b/tests/test_backend_commands.py new file mode 100644 index 0000000..b6ac306 --- /dev/null +++ b/tests/test_backend_commands.py @@ -0,0 +1,302 @@ +"""Tests for ACME backend command construction, parsing, and errors.""" + +from __future__ import annotations + +import os +import pathlib +from unittest.mock import AsyncMock, patch + +import pytest + +from acme_api.backend.acmesh_backend import ( + AcmeShBackend, + TerminalAcmeShError, + TransientAcmeShError, + _AcmeShBackendConfig, + _load_env_vars, + parse_cert_expiry, +) +from acme_api.backend.mock_backend import MockAcmeBackend + +SUCCESS_OUTPUT = ( + "Issue for domain: example.com\n" + "Your cert is in /acmesh/cert.pem, your cert key is in /acmesh/privkey.pem, " + "the CA certificates are in /acmesh/chain.pem, and the total chain length is 2.\n" + "*** Expired at: 2026-12-31 23:59:59+0000" +) + + +@pytest.fixture() +def tmp_config(tmp_path: pathlib.Path) -> _AcmeShBackendConfig: + """Return an isolated acme.sh backend config.""" + return _AcmeShBackendConfig( + binary_path=tmp_path / "acme.sh", + home_dir=tmp_path / "acmesh", + log_file=None, + force_renewal=False, + dnssleep_seconds=30, + ) + + +@pytest.fixture() +def backend(tmp_config: _AcmeShBackendConfig) -> AcmeShBackend: + """Return an acme.sh backend using the temporary config.""" + return AcmeShBackend(tmp_config) + + +def successful_process(output: str = SUCCESS_OUTPUT) -> AsyncMock: + """Return a mocked successful subprocess.""" + mock_proc = AsyncMock() + mock_proc.communicate.return_value = (output.encode(), b"") + mock_proc.returncode = 0 + return mock_proc + + +def failed_process(stderr: str, returncode: int = 1) -> AsyncMock: + """Return a mocked failed subprocess.""" + mock_proc = AsyncMock() + mock_proc.communicate.return_value = (b"", stderr.encode()) + mock_proc.returncode = returncode + return mock_proc + + +class TestRegisterAccountCommand: + """Verify the register command is constructed correctly.""" + + @pytest.mark.anyio + async def test_register_account_basic( + self, backend: AcmeShBackend, tmp_path: pathlib.Path + ) -> None: + with patch("asyncio.create_subprocess_exec", new_callable=AsyncMock) as mock_run: + mock_run.return_value = successful_process() + acct_key = tmp_path / "acmesh" / "acct.key" + acct_key.parent.mkdir(parents=True, exist_ok=True) + acct_key.write_bytes(b"fake-key") + + result = await backend.register_account( + email="admin@example.com", + server_url="https://acme-staging-v02.api.letsencrypt.org/directory", + ) + + call_args = mock_run.call_args.args + assert "--home" in call_args + assert str(tmp_path / "acmesh") in call_args + assert "--register" in call_args + assert "--email=admin@example.com" in call_args + assert "--server=https://acme-staging-v02.api.letsencrypt.org/directory" in call_args + assert "--nocaptcha" in call_args + assert "--accountkey-file" in call_args + assert str(acct_key) in call_args + assert result.email == "admin@example.com" + assert result.key_path == str(acct_key) + + +class TestIssueCertificateDns01: + """Verify DNS-01 challenge command construction.""" + + @pytest.mark.anyio + async def test_issue_certificate_dns_01(self, backend: AcmeShBackend) -> None: + with patch("asyncio.create_subprocess_exec", new_callable=AsyncMock) as mock_run: + mock_run.return_value = successful_process() + + result = await backend.issue_certificate( + domains=["example.com", "www.example.com"], + method="dns-01", + challenge_params={ + "dns_provider": "cloudflare", + "env_vars_file": None, + }, + ) + + call_args = mock_run.call_args.args + assert "--issue" in call_args + assert "--domain=example.com" in call_args + assert "--domain=www.example.com" in call_args + assert "--dns=cloudflare" in call_args + assert "--dnssleep" in call_args + assert "30" in call_args + assert result.domains == ["example.com", "www.example.com"] + + @pytest.mark.anyio + async def test_dns_credentials_are_passed_per_subprocess( + self, backend: AcmeShBackend, tmp_path: pathlib.Path + ) -> None: + env_file = tmp_path / "cloudflare.env" + env_file.write_text("export CF_Token='secret token'\n", encoding="utf-8") + + with patch.dict(os.environ, {}, clear=False), patch( + "asyncio.create_subprocess_exec", new_callable=AsyncMock + ) as mock_run: + mock_run.return_value = successful_process() + + await backend.issue_certificate( + domains=["example.com"], + method="dns-01", + challenge_params={ + "dns_provider": "cloudflare", + "env_vars_file": env_file, + }, + ) + + env = mock_run.call_args.kwargs["env"] + assert env["CF_Token"] == "secret token" + assert os.environ.get("CF_Token") is None + + +class TestIssueCertificateWebroot: + """Verify webroot command construction remains isolated from DNS flags.""" + + @pytest.mark.anyio + async def test_issue_certificate_webroot(self, backend: AcmeShBackend) -> None: + with patch("asyncio.create_subprocess_exec", new_callable=AsyncMock) as mock_run: + mock_run.return_value = successful_process() + + await backend.issue_certificate( + domains=["example.com"], + method="webroot", + challenge_params={"webroot_dir": "/var/www/certbot"}, + ) + + call_args = mock_run.call_args.args + assert "--issue" in call_args + assert "--domain=example.com" in call_args + assert "--webroot=/var/www/certbot" in call_args + assert "--dns=" not in " ".join(call_args) + + +class TestRenewCertificate: + """Verify renewal command construction.""" + + @pytest.mark.anyio + async def test_renew_certificate_force(self, backend: AcmeShBackend) -> None: + with patch("asyncio.create_subprocess_exec", new_callable=AsyncMock) as mock_run: + mock_run.return_value = successful_process() + + result = await backend.renew_certificate( + domains=["example.com", "www.example.com"], + force_renewal=True, + ) + + call_args = mock_run.call_args.args + assert "--renew" in call_args + assert "--domain=example.com" in call_args + assert "--domain=www.example.com" in call_args + assert "--force" in call_args + assert result.domains == ["example.com", "www.example.com"] + + +class TestParsing: + """Verify supported acme.sh output parsing variants.""" + + def test_parse_synthetic_issue_output(self) -> None: + result = parse_cert_expiry(SUCCESS_OUTPUT) + assert result.cert_path == "/acmesh/cert.pem" + assert result.privkey_path == "/acmesh/privkey.pem" + assert result.chain_path == "/acmesh/chain.pem" + assert result.fullchain_path == "/acmesh/fullchain.pem" + assert result.expires_at.year == 2026 + + def test_parse_common_multiline_acmesh_output(self) -> None: + output = ( + "Your cert is in /root/.acme.sh/example.com_ecc/example.com.cer\n" + "Your cert key is in /root/.acme.sh/example.com_ecc/example.com.key\n" + "The intermediate CA cert is in /root/.acme.sh/example.com_ecc/ca.cer\n" + "And the full chain certs is there: /root/.acme.sh/example.com_ecc/fullchain.cer\n" + "Le_NextRenewTimeStr='2026-12-31 23:59:59 UTC'\n" + ) + + result = parse_cert_expiry(output) + + assert result.cert_path.endswith("example.com.cer") + assert result.privkey_path.endswith("example.com.key") + assert result.chain_path.endswith("ca.cer") + assert result.fullchain_path.endswith("fullchain.cer") + assert result.expires_at.year == 2026 + + def test_parse_failure_is_terminal(self) -> None: + with pytest.raises(TerminalAcmeShError): + parse_cert_expiry("unexpected output") + + def test_load_env_vars_handles_exports_and_quotes(self, tmp_path: pathlib.Path) -> None: + env_file = tmp_path / "dns.env" + env_file.write_text( + "# comment\nexport CF_Token='secret token'\nCF_Account_ID=abc123\n", + encoding="utf-8", + ) + + assert _load_env_vars(env_file) == { + "CF_Token": "secret token", + "CF_Account_ID": "abc123", + } + + +class TestErrorClassification: + """Verify subprocess failures map to retryable or terminal errors.""" + + @pytest.mark.anyio + async def test_transient_dns_error(self, backend: AcmeShBackend) -> None: + with patch("asyncio.create_subprocess_exec", new_callable=AsyncMock) as mock_run: + mock_run.return_value = failed_process("TXT record not found") + + with pytest.raises(TransientAcmeShError): + await backend.issue_certificate( + domains=["example.com"], + method="dns-01", + challenge_params={"dns_provider": "cloudflare"}, + ) + + @pytest.mark.anyio + async def test_terminal_account_error(self, backend: AcmeShBackend) -> None: + with patch("asyncio.create_subprocess_exec", new_callable=AsyncMock) as mock_run: + mock_run.return_value = failed_process("account key invalid") + + with pytest.raises(TerminalAcmeShError): + await backend.issue_certificate( + domains=["example.com"], + method="dns-01", + challenge_params={"dns_provider": "cloudflare"}, + ) + + +class TestMockBackend: + """Verify the mock backend can satisfy future API tests without acme.sh.""" + + @pytest.mark.anyio + async def test_mock_backend_registers_account(self, tmp_path: pathlib.Path) -> None: + backend = MockAcmeBackend(tmp_path) + + result = await backend.register_account( + email="admin@example.com", + server_url="https://example.test/acme", + ) + + assert result.email == "admin@example.com" + assert result.server_url == "https://example.test/acme" + assert result.key_path == str(tmp_path / "acct.key") + + @pytest.mark.anyio + async def test_mock_backend_issues_certificate(self, tmp_path: pathlib.Path) -> None: + backend = MockAcmeBackend(tmp_path) + + result = await backend.issue_certificate( + domains=["example.com"], + method="dns-01", + challenge_params={"dns_provider": "mock"}, + ) + + assert result.domains == ["example.com"] + assert result.cert.cert_path.endswith("example.com/cert.pem") + + @pytest.mark.anyio + async def test_mock_backend_renews_and_reads_expiry(self, tmp_path: pathlib.Path) -> None: + backend = MockAcmeBackend(tmp_path) + + renewed = await backend.renew_certificate( + domains=["*.example.com", "example.com"], + force_renewal=True, + ) + expiry = await backend.get_certificate_expiry("/custom/cert.pem") + + assert renewed.domains == ["*.example.com", "example.com"] + assert "wildcard.example.com" in renewed.cert.cert_path + assert expiry.cert_path == "/custom/cert.pem" From 82a9103a6c4dfa2c07ab4e91c7a20794d879bdc9 Mon Sep 17 00:00:00 2001 From: Streaky Date: Thu, 2 Jul 2026 12:12:53 +0100 Subject: [PATCH 12/26] phase 4 and 5 implementation --- .gitignore | 2 + acme_api/auth/__init__.py | 3 + acme_api/auth/bootstrap.py | 64 +++++++ acme_api/auth/hash.py | 92 ++++++++++ acme_api/auth/rbac.py | 116 +++++++++++++ acme_api/config.py | 4 + acme_api/db.py | 10 ++ acme_api/main.py | 26 ++- acme_api/models/__init__.py | 3 + acme_api/models/api_key.py | 73 ++++++++ acme_api/routers/__init__.py | 9 + acme_api/routers/certificates.py | 163 ++++++++++++++++++ acme_api/routers/config.py | 50 ++++++ acme_api/routers/events.py | 44 +++++ .../versions/8760d3a7fed0_initial_schema.py | 18 ++ config.example.yaml | 7 + tests/conftest.py | 10 +- tests/test_api_phase5.py | 111 ++++++++++++ tests/test_auth_phase4.py | 159 +++++++++++++++++ tests/test_crud.py | 8 +- tests/test_db.py | 105 ++++++----- tests/test_health.py | 2 +- 22 files changed, 1025 insertions(+), 54 deletions(-) create mode 100644 acme_api/auth/__init__.py create mode 100644 acme_api/auth/bootstrap.py create mode 100644 acme_api/auth/hash.py create mode 100644 acme_api/auth/rbac.py create mode 100644 acme_api/models/api_key.py create mode 100644 acme_api/routers/__init__.py create mode 100644 acme_api/routers/certificates.py create mode 100644 acme_api/routers/config.py create mode 100644 acme_api/routers/events.py create mode 100644 tests/test_api_phase5.py create mode 100644 tests/test_auth_phase4.py diff --git a/.gitignore b/.gitignore index 1701ffe..5bb9c62 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ .pycache/ .mypy_cache/ __pycache__ +*.pyc +.pytest_cache/ coverage-data/ .coverage diff --git a/acme_api/auth/__init__.py b/acme_api/auth/__init__.py new file mode 100644 index 0000000..7b9f94d --- /dev/null +++ b/acme_api/auth/__init__.py @@ -0,0 +1,3 @@ +"""Phase 4 - Authentication & Authorization System""" + +__all__ = ["hash", "rbac"] diff --git a/acme_api/auth/bootstrap.py b/acme_api/auth/bootstrap.py new file mode 100644 index 0000000..b1368eb --- /dev/null +++ b/acme_api/auth/bootstrap.py @@ -0,0 +1,64 @@ +"""Bootstrap API keys from configuration.""" + +from __future__ import annotations + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from acme_api.auth.hash import hash_api_key +from acme_api.config import AppSettings +from acme_api.models.api_key import APIKey, APIKeyRole + + +async def seed_initial_keys(db: AsyncSession, settings: AppSettings) -> list[APIKey]: + """Create initial admin/operator/readonly keys from config. + + Keys come from ``AppSettings.api_keys`` dict (role → raw key string). + Only non-empty entries are seeded; missing roles mean that role is not bootstrapped. + + Args: + db: Async SQLAlchemy session. + settings: Runtime settings containing bootstrap key material. + + Returns: + List of created :class:`APIKey` instances. + """ + keys_raw = settings.api_keys + + created: list[APIKey] = [] + for role_str, raw_key in keys_raw.items(): + if not raw_key: + continue + try: + role = APIKeyRole(role_str) + except ValueError as exc: + raise ValueError( + f"Invalid bootstrap key role '{role_str}'. " + f"Valid roles: {[r.value for r in APIKeyRole]}" + ) from exc + + existing = await db.scalar( + select(APIKey).where( + APIKey.name == f"bootstrap-{role.value}", + ) + ) + if existing is not None: + continue + + hashed = hash_api_key(raw_key) + key_obj = APIKey( + name=f"bootstrap-{role.value}", + hashed_key=hashed, + role=role, + is_active=True, + ) + db.add(key_obj) + created.append(key_obj) + + if created: + await db.commit() + + return created + + +__all__ = ["seed_initial_keys"] diff --git a/acme_api/auth/hash.py b/acme_api/auth/hash.py new file mode 100644 index 0000000..24d37fe --- /dev/null +++ b/acme_api/auth/hash.py @@ -0,0 +1,92 @@ +"""API key material hashing and verification utilities.""" + +from __future__ import annotations + +import uuid as _uuid +from dataclasses import dataclass +from datetime import datetime +from typing import cast + +from passlib.hash import pbkdf2_sha512 as _pbkdf2_sha512 # type: ignore[import-untyped] + +from acme_api.models.api_key import APIKeyRole + +_HASH_ROUNDS = 200_000 + + +def hash_api_key(raw_key: str) -> str: + """Return a PBKDF2-SHA512 hash of the raw API key material. + + PBKDF2 is preferred over bcrypt for high-entropy secrets like API keys, + because bcrypt has a 72-byte input truncation limit and newer versions of + ``bcrypt`` are incompatible with passlib's legacy wrapper code. + + Args: + raw_key: The plaintext API key material (minimum 8 characters). + + Returns: + A hash string suitable for storage in the database. + + Raises: + ValueError: If ``raw_key`` is empty or shorter than 8 characters. + """ + if not raw_key or len(raw_key) < 8: + raise ValueError("API key must be at least 8 characters long.") + return cast(str, _pbkdf2_sha512.using(rounds=_HASH_ROUNDS).hash(raw_key)) + + +def verify_api_key(candidate: str, stored_hash: str) -> bool: + """Return ``True`` when ``candidate`` matches the PBKDF2-stored hash. + + Args: + candidate: The plaintext API key from the request header. + stored_hash: The PBKDF2-SHA512 hash stored in the database. + + Returns: + Whether the two values match. + """ + if not candidate or not stored_hash: + return False + try: + return cast(bool, _pbkdf2_sha512.verify(candidate, stored_hash)) + except ValueError: # pragma: no cover — defensive guard only + return False + + +@dataclass(frozen=True) +class AuthenticatedUser: + """Represents an authenticated user after token validation. + + Attributes: + key_id: The unique identifier of the API key. + role: The access level granted by this key (admin/operator/readonly). + name: Human-readable label for the key. + expires_at: Optional expiration datetime string. + """ + + key_id: _uuid.UUID + role: APIKeyRole + name: str | None = None + expires_at: datetime | None = None + + +class AuthenticationError(Exception): + """Base class for authentication failures.""" + + def __init__(self, message: str, status_code: int = 401) -> None: + super().__init__(message) + self.status_code = status_code + + +class ForbiddenError(AuthenticationError): + """Raised when role permissions are insufficient. + + Attributes: + required_role: The minimum role needed for this operation (may be ``None``). + """ + + def __init__( + self, message: str, required_role: APIKeyRole | None = None + ) -> None: + super().__init__(message, status_code=403) + self.required_role = required_role diff --git a/acme_api/auth/rbac.py b/acme_api/auth/rbac.py new file mode 100644 index 0000000..fdc25de --- /dev/null +++ b/acme_api/auth/rbac.py @@ -0,0 +1,116 @@ +"""RBAC FastAPI dependencies for API key authorization.""" + +from __future__ import annotations + +import datetime as _dt + +from fastapi import Depends, HTTPException, Request, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from acme_api.auth.hash import AuthenticatedUser, verify_api_key +from acme_api.db import get_db_session +from acme_api.models.api_key import APIKey, APIKeyRole + + +async def require_admin( + request: Request, db_session: AsyncSession = Depends(get_db_session) +) -> AuthenticatedUser: + """Require the request holder to have admin role.""" + user = await _authenticate(request, db_session) + if user.role != APIKeyRole.ADMIN: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Admin privileges required.", + ) + return user + + +async def require_operator( + request: Request, db_session: AsyncSession = Depends(get_db_session) +) -> AuthenticatedUser: + """Require the request holder to have operator role or higher.""" + user = await _authenticate(request, db_session) + allowed = {APIKeyRole.ADMIN, APIKeyRole.OPERATOR} + if user.role not in allowed: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Operator privileges required.", + ) + return user + + +async def require_readonly( + request: Request, db_session: AsyncSession = Depends(get_db_session) +) -> AuthenticatedUser: + """Require the request holder to have any authenticated role.""" + return await _authenticate(request, db_session) + + +async def _authenticate(request: Request, db_session: AsyncSession) -> AuthenticatedUser: + """Validate Bearer token and return the user if authorized. + + Args: + request: Incoming FastAPI request. + db_session: Database session from ``get_db`` dependency. + + Returns: + An :class:`AuthenticatedUser` on success. + + Raises: + HTTPException 401 if no valid Bearer token is provided or key not found. + HTTPException 403 if key is inactive, expired, or lacks required privilege. + """ + auth_header = request.headers.get("authorization") + if not auth_header or not auth_header.startswith("Bearer "): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Missing API key.", + headers={"WWW-Authenticate": "Bearer"}, + ) + + raw_token = auth_header[7:] # Strip 'Bearer ' prefix + if not raw_token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Empty Bearer token.", + headers={"WWW-Authenticate": "Bearer"}, + ) + + statement = select(APIKey).where(APIKey.is_active.is_(True)) + result = await db_session.execute(statement) + api_key = next( + ( + candidate + for candidate in result.scalars() + if verify_api_key(raw_token, candidate.hashed_key) + ), + None, + ) + + if not api_key: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid API key.", + headers={"WWW-Authenticate": "Bearer"}, + ) + + now = _dt.datetime.now(_dt.timezone.utc) + expires_at = api_key.expires_at + if expires_at and expires_at.tzinfo is None: + expires_at = expires_at.replace(tzinfo=_dt.timezone.utc) + if expires_at and now > expires_at: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="API key has expired.", + ) + + return AuthenticatedUser( + key_id=api_key.id, + role=api_key.role, + name=api_key.name, + expires_at=expires_at, + ) + + +__all__ = ["require_admin", "require_operator", "require_readonly"] diff --git a/acme_api/config.py b/acme_api/config.py index bf894cf..61dc1bf 100644 --- a/acme_api/config.py +++ b/acme_api/config.py @@ -111,6 +111,10 @@ class AppSettings(StrictConfigModel): renewal: RenewalConfig = Field(default_factory=RenewalConfig) dns_providers: list[DnsProviderConfig] = Field(default_factory=list) acme_accounts: list[AcmeAccountConfig] = Field(default_factory=list) + api_keys: dict[str, str] = Field( + default_factory=dict, + description="Bootstrap API keys for initial admin/operator/readonly access.", + ) def check(self) -> None: """Validate configuration at startup. diff --git a/acme_api/db.py b/acme_api/db.py index 2c4a0f9..7a3a51c 100644 --- a/acme_api/db.py +++ b/acme_api/db.py @@ -78,6 +78,16 @@ async def get_db() -> typing.AsyncIterator[AsyncSession]: """FastAPI dependency that yields an async database session.""" session_factory = get_session_factory() async with session_factory() as session: + try: + yield session + except Exception: + await session.rollback() + raise + + +async def get_db_session() -> typing.AsyncIterator[AsyncSession]: + """Yield an async database session for FastAPI dependency injection.""" + async with get_db() as session: yield session diff --git a/acme_api/main.py b/acme_api/main.py index 462b00d..b15ed7d 100644 --- a/acme_api/main.py +++ b/acme_api/main.py @@ -14,22 +14,26 @@ from fastapi import FastAPI, Request, Response from fastapi.responses import JSONResponse +from acme_api.auth.bootstrap import seed_initial_keys from acme_api.config import AppSettings, load_config, prepare_runtime_paths +from acme_api.db import get_db, init_db, init_engine from acme_api.logging import setup_logging from acme_api.middleware import RequestIdMiddleware +from acme_api.routers import certificates_router, config_router, events_router @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncGenerator[None]: """Application lifespan handler. - Configures structured logging and validates settings on startup. + Configures structured logging, initializes the async DB engine, seeds API + keys from config on first boot, and validates settings before accepting + requests. On shutdown, closes all session factories cleanly. """ settings = app.state.settings - # Prepare directories before validation checks that they are usable. + prepare_runtime_paths(settings) settings.check() - # Configure structured logging according to settings setup_logging(level=settings.log.level, format_type=settings.log.format) root_logger = logging.getLogger(__name__) root_logger.info( @@ -37,7 +41,20 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]: settings.database.url, settings.deployment.directory, ) + + # Phase 4: initialize engine (sets up session factory) and seed API keys. + engine = init_engine(settings) + await init_db(engine) + async with get_db() as session: + created_keys = await seed_initial_keys(session, settings) + for key in created_keys: + root_logger.info( + "seeded api key | name=%s role=%s", key.name, key.role.value + ) + yield + + await engine.dispose() root_logger.info("acme.api shutting down") @@ -68,6 +85,9 @@ def create_app(settings: AppSettings | None = None) -> FastAPI: # Middleware: request ID injection (outermost) app.add_middleware(RequestIdMiddleware) + app.include_router(certificates_router) + app.include_router(config_router) + app.include_router(events_router) @app.get("/health", tags=["Health"]) async def health() -> dict[str, str]: diff --git a/acme_api/models/__init__.py b/acme_api/models/__init__.py index a79032f..6fc4bdc 100644 --- a/acme_api/models/__init__.py +++ b/acme_api/models/__init__.py @@ -14,12 +14,15 @@ from __future__ import annotations +from acme_api.models.api_key import APIKey, APIKeyRole from acme_api.models.base import Base, TimestampMixin from acme_api.models.certificate import Certificate, CertificateStatus from acme_api.models.event import Event from acme_api.models.renewal_attempt import RenewalAttempt __all__ = [ + "APIKey", + "APIKeyRole", "Base", "Certificate", "CertificateStatus", diff --git a/acme_api/models/api_key.py b/acme_api/models/api_key.py new file mode 100644 index 0000000..ad862de --- /dev/null +++ b/acme_api/models/api_key.py @@ -0,0 +1,73 @@ +"""API Key SQLAlchemy model for authentication and authorization.""" + +from __future__ import annotations + +import datetime as _dt +import enum +import uuid as _uuid + +from sqlalchemy import Boolean, DateTime, Enum, String, Uuid, text +from sqlalchemy.orm import Mapped, mapped_column + +from acme_api.models.base import Base, TimestampMixin + + +class APIKeyRole(enum.StrEnum): + """Roles for API key access control.""" + + ADMIN = "admin" + OPERATOR = "operator" + READONLY = "readonly" + + +class APIKey(Base, TimestampMixin): + """Row representing an API key used for authentication. + + Keys are stored as PBKDF2 hashes and validated on each request via + the ``Authorization: Bearer `` header. The role determines which + endpoints the holder may access. + """ + + __tablename__ = "api_keys" + + # -- primary key ---------------------------------------------------------- + id: Mapped[_uuid.UUID] = mapped_column( + Uuid(), + primary_key=True, + default=_uuid.uuid4, + doc="Unique identifier for this API key.", + ) + + # -- identity ------------------------------------------------------------- + name: Mapped[str] = mapped_column( + String(255), + unique=True, + nullable=False, + index=True, + doc="Human-readable label (e.g. CI pipeline, operator account).", + ) + hashed_key: Mapped[str] = mapped_column( + String(600), + nullable=False, + doc="PBKDF2 hash of the raw API key material.", + ) + role: Mapped[APIKeyRole] = mapped_column( + Enum(APIKeyRole), + nullable=False, + server_default=text("'READONLY'"), + doc="Access level granted by this key.", + ) + + # -- status & expiry ------------------------------------------------------ + is_active: Mapped[bool] = mapped_column( + Boolean, + default=True, + server_default=text("1"), + nullable=False, + doc="Whether this key is currently valid for authentication.", + ) + expires_at: Mapped[_dt.datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + doc="Optional expiration datetime. ``None`` means the key does not expire.", + ) diff --git a/acme_api/routers/__init__.py b/acme_api/routers/__init__.py new file mode 100644 index 0000000..9c9b64e --- /dev/null +++ b/acme_api/routers/__init__.py @@ -0,0 +1,9 @@ +"""FastAPI router registry.""" + +from __future__ import annotations + +from acme_api.routers.certificates import router as certificates_router +from acme_api.routers.config import router as config_router +from acme_api.routers.events import router as events_router + +__all__ = ["certificates_router", "config_router", "events_router"] diff --git a/acme_api/routers/certificates.py b/acme_api/routers/certificates.py new file mode 100644 index 0000000..7eb8d48 --- /dev/null +++ b/acme_api/routers/certificates.py @@ -0,0 +1,163 @@ +"""Certificate lifecycle API routes.""" + +from __future__ import annotations + +import uuid + +from fastapi import APIRouter, Depends, HTTPException, Query, Response, status +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from acme_api.auth.rbac import require_operator, require_readonly +from acme_api.db import get_db_session +from acme_api.models.certificate import Certificate, CertificateStatus +from acme_api.models.event import Event +from acme_api.schemas.certificate import CertificateCreate, CertificateRead + +router = APIRouter(prefix="/v1/certificates", tags=["Certificates"]) + + +@router.post( + "", + response_model=CertificateRead, + status_code=status.HTTP_202_ACCEPTED, + summary="Create a certificate request", +) +async def create_certificate( + payload: CertificateCreate, + _: object = Depends(require_operator), + db_session: AsyncSession = Depends(get_db_session), +) -> Certificate: + """Create a pending certificate record and audit event.""" + certificate = Certificate( + name=payload.name, + domains=payload.domains, + acme_account_ref=payload.acme_account_ref, + dns_provider_ref=payload.dns_provider_ref, + key_algorithm=payload.key_algorithm, + status=CertificateStatus.PENDING, + ) + db_session.add(certificate) + try: + await db_session.flush() + except IntegrityError as exc: + await db_session.rollback() + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Certificate name already exists.", + ) from exc + + db_session.add( + Event( + event_type="certificate.created", + certificate_id=certificate.id, + details={"name": certificate.name, "domains": certificate.domains}, + ) + ) + await db_session.commit() + await db_session.refresh(certificate) + return certificate + + +@router.get( + "", + response_model=list[CertificateRead], + summary="List certificates", +) +async def list_certificates( + _: object = Depends(require_readonly), + db_session: AsyncSession = Depends(get_db_session), + offset: int = Query(default=0, ge=0), + limit: int = Query(default=100, ge=1, le=500), + status_filter: CertificateStatus | None = Query(default=None, alias="status"), + domain: str | None = Query(default=None, min_length=1), +) -> list[Certificate]: + """Return certificates with pagination and optional filters.""" + statement = select(Certificate).offset(offset).limit(limit) + if status_filter is not None: + statement = statement.where(Certificate.status == status_filter) + if domain is not None: + statement = statement.where(Certificate.domains.contains(domain)) + result = await db_session.execute(statement.order_by(Certificate.created_at.desc())) + return list(result.scalars().all()) + + +@router.get( + "/{certificate_id}", + response_model=CertificateRead, + summary="Get certificate detail", +) +async def get_certificate( + certificate_id: uuid.UUID, + _: object = Depends(require_readonly), + db_session: AsyncSession = Depends(get_db_session), +) -> Certificate: + """Return a single certificate by ID.""" + certificate = await db_session.get(Certificate, certificate_id) + if certificate is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Certificate not found.", + ) + return certificate + + +@router.delete( + "/{certificate_id}", + status_code=status.HTTP_204_NO_CONTENT, + summary="Revoke a certificate record", +) +async def revoke_certificate( + certificate_id: uuid.UUID, + _: object = Depends(require_operator), + db_session: AsyncSession = Depends(get_db_session), +) -> Response: + """Soft-delete a certificate by marking it revoked.""" + certificate = await db_session.get(Certificate, certificate_id) + if certificate is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Certificate not found.", + ) + certificate.status = CertificateStatus.REVOKED + db_session.add( + Event( + event_type="certificate.revoked", + certificate_id=certificate.id, + details={"name": certificate.name}, + ) + ) + await db_session.commit() + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@router.post( + "/{certificate_id}/renew", + response_model=CertificateRead, + status_code=status.HTTP_202_ACCEPTED, + summary="Trigger certificate renewal", +) +async def renew_certificate( + certificate_id: uuid.UUID, + _: object = Depends(require_operator), + db_session: AsyncSession = Depends(get_db_session), +) -> Certificate: + """Mark a certificate as renewing and record the manual trigger.""" + certificate = await db_session.get(Certificate, certificate_id) + if certificate is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Certificate not found.", + ) + certificate.status = CertificateStatus.RENEWING + db_session.add( + Event( + event_type="certificate.renewal_requested", + certificate_id=certificate.id, + details={"name": certificate.name}, + ) + ) + await db_session.commit() + await db_session.refresh(certificate) + return certificate diff --git a/acme_api/routers/config.py b/acme_api/routers/config.py new file mode 100644 index 0000000..3a37d2a --- /dev/null +++ b/acme_api/routers/config.py @@ -0,0 +1,50 @@ +"""Read-only routes for config-owned integrations.""" + +from __future__ import annotations + +from typing import cast + +from fastapi import APIRouter, Depends, Request + +from acme_api.auth.rbac import require_readonly +from acme_api.config import AppSettings +from acme_api.schemas.config_readonly import AcmeAccountRead, DnsProviderRead + +router = APIRouter(prefix="/v1", tags=["Configuration"]) + + +def get_settings(request: Request) -> AppSettings: + """Return application settings from FastAPI state.""" + return cast(AppSettings, request.app.state.settings) + + +@router.get( + "/accounts", + response_model=list[AcmeAccountRead], + summary="List ACME accounts", +) +async def list_acme_accounts( + _: object = Depends(require_readonly), + settings: AppSettings = Depends(get_settings), +) -> list[AcmeAccountRead]: + """Return configured ACME account aliases.""" + return [ + AcmeAccountRead(name=account.name, server_url=account.server_url) + for account in settings.acme_accounts + ] + + +@router.get( + "/providers", + response_model=list[DnsProviderRead], + summary="List DNS providers", +) +async def list_dns_providers( + _: object = Depends(require_readonly), + settings: AppSettings = Depends(get_settings), +) -> list[DnsProviderRead]: + """Return configured DNS provider aliases.""" + return [ + DnsProviderRead(name=provider.name, provider_name=provider.provider_name) + for provider in settings.dns_providers + ] diff --git a/acme_api/routers/events.py b/acme_api/routers/events.py new file mode 100644 index 0000000..f8210d1 --- /dev/null +++ b/acme_api/routers/events.py @@ -0,0 +1,44 @@ +"""Audit event API routes.""" + +from __future__ import annotations + +import datetime as dt +import uuid + +from fastapi import APIRouter, Depends, Query +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from acme_api.auth.rbac import require_readonly +from acme_api.db import get_db_session +from acme_api.models.event import Event +from acme_api.schemas.event import EventRead + +router = APIRouter(prefix="/v1/events", tags=["Events"]) + + +@router.get("", response_model=list[EventRead], summary="List audit events") +async def list_events( # pylint: disable=too-many-arguments,too-many-positional-arguments + _: object = Depends(require_readonly), + db_session: AsyncSession = Depends(get_db_session), + offset: int = Query(default=0, ge=0), + limit: int = Query(default=100, ge=1, le=500), + event_type: str | None = Query(default=None, min_length=1, max_length=64), + certificate_id: uuid.UUID | None = None, + since: dt.datetime | None = None, + until: dt.datetime | None = None, +) -> list[Event]: + """Return audit events with pagination and optional filters.""" + statement = select(Event) + if event_type is not None: + statement = statement.where(Event.event_type == event_type) + if certificate_id is not None: + statement = statement.where(Event.certificate_id == certificate_id) + if since is not None: + statement = statement.where(Event.timestamp >= since) + if until is not None: + statement = statement.where(Event.timestamp <= until) + + statement = statement.order_by(Event.timestamp.desc()).offset(offset).limit(limit) + result = await db_session.execute(statement) + return list(result.scalars().all()) diff --git a/alembic/versions/8760d3a7fed0_initial_schema.py b/alembic/versions/8760d3a7fed0_initial_schema.py index 882edee..b889c93 100644 --- a/alembic/versions/8760d3a7fed0_initial_schema.py +++ b/alembic/versions/8760d3a7fed0_initial_schema.py @@ -21,6 +21,20 @@ def upgrade() -> None: """Upgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### + op.create_table('api_keys', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('hashed_key', sa.String(length=600), nullable=False), + sa.Column('role', sa.Enum('ADMIN', 'OPERATOR', 'READONLY', name='apikeyrole'), server_default=sa.text("'READONLY'"), nullable=False), + sa.Column('is_active', sa.Boolean(), server_default=sa.text('1'), nullable=False), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('api_keys', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_api_keys_name'), ['name'], unique=True) + op.create_table('certificates', sa.Column('id', sa.Uuid(), nullable=False), sa.Column('name', sa.String(length=255), nullable=False), @@ -81,4 +95,8 @@ def downgrade() -> None: batch_op.drop_index(batch_op.f('ix_certificates_name')) op.drop_table('certificates') + with op.batch_alter_table('api_keys', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_api_keys_name')) + + op.drop_table('api_keys') # ### end Alembic commands ### diff --git a/config.example.yaml b/config.example.yaml index d1dbac8..dedac2b 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -24,6 +24,13 @@ renewal: window_days: 30 max_retries: 3 +# Optional bootstrap API keys. Values are raw secrets read once and stored as +# hashes in SQLite if the named bootstrap key does not already exist. +api_keys: + admin: change-me-admin-key + operator: change-me-operator-key + readonly: change-me-readonly-key + # DNS provider aliases referenced by name in the API. # Each provider must have a credentials file with acme.sh-compatible env vars. dns_providers: diff --git a/tests/conftest.py b/tests/conftest.py index 8a0684b..509bb1d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,7 +8,7 @@ import pytest from fastapi import FastAPI -from httpx import ASGITransport, AsyncClient +from httpx2 import ASGITransport, AsyncClient # Ensure the project root is on sys.path regardless of invocation directory. ROOT = Path(__file__).resolve().parent.parent @@ -25,7 +25,12 @@ def anyio_backend() -> str: @pytest.fixture() def app(tmp_path: Path) -> FastAPI: """Create a FastAPI app with an isolated temporary database path.""" - from acme_api.config import AppSettings, DatabaseConfig, DeploymentConfig + from acme_api.config import ( + AcmeConfig, + AppSettings, + DatabaseConfig, + DeploymentConfig, + ) from acme_api.main import create_app db_dir = tmp_path / "data" @@ -36,6 +41,7 @@ def app(tmp_path: Path) -> FastAPI: settings = AppSettings( database=DatabaseConfig(url=f"sqlite+aiosqlite:///{db_dir}/acme.db"), deployment=DeploymentConfig(directory=deploy_dir), + acme=AcmeConfig(home_dir=tmp_path / "acmesh"), ) return create_app(settings=settings) diff --git a/tests/test_api_phase5.py b/tests/test_api_phase5.py new file mode 100644 index 0000000..67d567b --- /dev/null +++ b/tests/test_api_phase5.py @@ -0,0 +1,111 @@ +"""Tests for Phase 5 REST API endpoints.""" + +from __future__ import annotations + +from pathlib import Path + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from acme_api.config import ( + AcmeAccountConfig, + AcmeConfig, + AppSettings, + DatabaseConfig, + DeploymentConfig, + DnsProviderConfig, +) +from acme_api.main import create_app + + +def _make_app(tmp_path: Path) -> FastAPI: + env_file = tmp_path / "cloudflare.env" + env_file.write_text("CF_Token=test\n", encoding="utf-8") + settings = AppSettings( + database=DatabaseConfig(url=f"sqlite+aiosqlite:///{tmp_path}/test.db"), + deployment=DeploymentConfig(directory=tmp_path / "certs"), + acme=AcmeConfig(home_dir=tmp_path / "acmesh"), + dns_providers=[ + DnsProviderConfig( + name="cloudflare-main", + provider_name="cloudflare", + env_vars_file_path=env_file, + ) + ], + acme_accounts=[AcmeAccountConfig(name="letsencrypt-production")], + api_keys={ + "admin": "admin-key-12345", + "operator": "operator-key-12345", + "readonly": "readonly-key-12345", + }, + ) + return create_app(settings=settings) + + +def test_certificate_lifecycle_endpoints(tmp_path: Path) -> None: + """Create, list, read, renew, and revoke a certificate.""" + headers = {"Authorization": "Bearer operator-key-12345"} + with TestClient(_make_app(tmp_path)) as client: + created = client.post( + "/v1/certificates", + headers=headers, + json={ + "name": "example-cert", + "domains": ["example.com", "www.example.com"], + "acme_account_ref": "letsencrypt-production", + "dns_provider_ref": "cloudflare-main", + }, + ) + assert created.status_code == 202 + body = created.json() + assert body["status"] == "pending" + + certificate_id = body["id"] + listed = client.get("/v1/certificates?domain=example.com", headers=headers) + assert listed.status_code == 200 + assert [item["id"] for item in listed.json()] == [certificate_id] + + detail = client.get(f"/v1/certificates/{certificate_id}", headers=headers) + assert detail.status_code == 200 + assert detail.json()["name"] == "example-cert" + + renewed = client.post( + f"/v1/certificates/{certificate_id}/renew", headers=headers + ) + assert renewed.status_code == 202 + assert renewed.json()["status"] == "renewing" + + deleted = client.delete(f"/v1/certificates/{certificate_id}", headers=headers) + assert deleted.status_code == 204 + + revoked = client.get(f"/v1/certificates/{certificate_id}", headers=headers) + assert revoked.json()["status"] == "revoked" + + +def test_config_and_events_endpoints(tmp_path: Path) -> None: + """List config-owned integrations and audit events.""" + headers = {"Authorization": "Bearer readonly-key-12345"} + operator_headers = {"Authorization": "Bearer operator-key-12345"} + with TestClient(_make_app(tmp_path)) as client: + assert client.get("/v1/accounts", headers=headers).json()[0]["name"] == ( + "letsencrypt-production" + ) + assert client.get("/v1/providers", headers=headers).json()[0]["name"] == ( + "cloudflare-main" + ) + client.post( + "/v1/certificates", + headers=operator_headers, + json={ + "name": "event-cert", + "domains": ["events.example.com"], + "acme_account_ref": "letsencrypt-production", + "dns_provider_ref": "cloudflare-main", + }, + ) + + events = client.get( + "/v1/events?event_type=certificate.created", headers=headers + ) + assert events.status_code == 200 + assert events.json()[0]["event_type"] == "certificate.created" diff --git a/tests/test_auth_phase4.py b/tests/test_auth_phase4.py new file mode 100644 index 0000000..6dfd130 --- /dev/null +++ b/tests/test_auth_phase4.py @@ -0,0 +1,159 @@ +"""Tests for Phase 4 Authentication & Authorization.""" + +from __future__ import annotations + +import datetime as _dt +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from acme_api.auth.hash import ( + AuthenticatedUser, + AuthenticationError, + hash_api_key, + verify_api_key, +) +from acme_api.config import AcmeConfig, AppSettings, DatabaseConfig, DeploymentConfig +from acme_api.main import create_app +from acme_api.models.api_key import APIKey, APIKeyRole + + +@pytest.fixture +def sample_keys() -> dict[str, str]: + """Sample raw API keys for testing.""" + return { + "admin": "admin-key-12345", + "operator": "operator-key-12345", + "readonly": "readonly-key-12345", + } + + +class TestHashUtility: + def test_hash_api_key_creates_hash(self, sample_keys: dict[str, str]) -> None: + """hash_api_key returns a valid PBKDF2 hash.""" + raw = sample_keys["admin"] + hashed = hash_api_key(raw) + + assert hashed != raw + assert len(hashed) > 50 # PBKDF2 hashes are long + + def test_verify_correct_password(self, sample_keys: dict[str, str]) -> None: + """verify_api_key returns True for matching credentials.""" + raw = sample_keys["operator"] + hashed = hash_api_key(raw) + + assert verify_api_key(raw, hashed) is True + + def test_verify_incorrect_password(self, sample_keys: dict[str, str]) -> None: + """verify_api_key returns False for wrong password.""" + raw = sample_keys["admin"] + hashed = hash_api_key(raw) + + assert verify_api_key("wrong-password", hashed) is False + + def test_hash_empty_input_raises(self) -> None: + """hash_api_key raises ValueError for empty input.""" + with pytest.raises(ValueError, match="must be at least 8 characters"): + hash_api_key("") + + +class TestAuthenticatedUser: + def test_authenticated_user_creation(self, sample_keys: dict[str, str]) -> None: + """Can create AuthenticatedUser from APIKey model.""" + raw = sample_keys["admin"] + hashed = hash_api_key(raw) + + key_obj = APIKey( + name="test-admin", + hashed_key=hashed, + role=APIKeyRole.ADMIN, + is_active=True, + expires_at=_dt.datetime.now(_dt.timezone.utc) + _dt.timedelta(days=365), + ) + + user = AuthenticatedUser( + key_id=key_obj.id, + role=key_obj.role, + name=key_obj.name, + expires_at=key_obj.expires_at, + ) + + assert user.role == APIKeyRole.ADMIN + assert user.name == "test-admin" + + +class TestRBACDependencies: + def test_admin_role_valid(self, sample_keys: dict[str, str]) -> None: + """Admin role can be validated.""" + raw = sample_keys["admin"] + hashed = hash_api_key(raw) + + assert verify_api_key(raw, hashed) is True + + def test_operator_role_valid(self, sample_keys: dict[str, str]) -> None: + """Operator role can be validated.""" + raw = sample_keys["operator"] + hashed = hash_api_key(raw) + + assert verify_api_key(raw, hashed) is True + + def test_readonly_role_valid(self, sample_keys: dict[str, str]) -> None: + """Read-only role can be validated.""" + raw = sample_keys["readonly"] + hashed = hash_api_key(raw) + + assert verify_api_key(raw, hashed) is True + + +class TestAuthenticationErrors: + def test_authentication_error_has_status_code(self) -> None: + """AuthenticationError includes HTTP status code.""" + err = AuthenticationError("Not authorized") + + assert err.status_code == 401 + + +class TestRBACRoutes: + def test_route_auth_matrix(self, tmp_path: Path) -> None: + """Routes return 401/403/2xx according to API key role.""" + settings = AppSettings( + database=DatabaseConfig(url=f"sqlite+aiosqlite:///{tmp_path}/test.db"), + deployment=DeploymentConfig(directory=tmp_path / "certs"), + acme=AcmeConfig(home_dir=tmp_path / "acmesh"), + api_keys={ + "admin": "admin-key-12345", + "operator": "operator-key-12345", + "readonly": "readonly-key-12345", + }, + ) + app = create_app(settings=settings) + + with TestClient(app) as client: + assert client.get("/v1/certificates").status_code == 401 + readonly_headers = {"Authorization": "Bearer readonly-key-12345"} + operator_headers = {"Authorization": "Bearer operator-key-12345"} + + assert client.get( + "/v1/certificates", headers=readonly_headers + ).status_code == 200 + assert client.post( + "/v1/certificates", + headers=readonly_headers, + json={ + "name": "example", + "domains": ["example.com"], + "acme_account_ref": "le", + "dns_provider_ref": "cf", + }, + ).status_code == 403 + assert client.post( + "/v1/certificates", + headers=operator_headers, + json={ + "name": "example", + "domains": ["example.com"], + "acme_account_ref": "le", + "dns_provider_ref": "cf", + }, + ).status_code == 202 diff --git a/tests/test_crud.py b/tests/test_crud.py index 12831d6..d283d26 100644 --- a/tests/test_crud.py +++ b/tests/test_crud.py @@ -28,8 +28,12 @@ def settings(tmp_path: Path) -> AppSettings: @pytest.fixture() -def engine(settings: AppSettings) -> AsyncEngine: - return init_engine(settings=settings) +async def engine(settings: AppSettings) -> AsyncGenerator[AsyncEngine, None]: + engine = init_engine(settings=settings) + try: + yield engine + finally: + await engine.dispose() @pytest.fixture() diff --git a/tests/test_db.py b/tests/test_db.py index 831c339..7f2ae2b 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -62,59 +62,68 @@ class TestGetDb: async def test_yields_active_session(self, settings: AppSettings) -> None: """get_db yields an AsyncSession that can execute queries.""" engine = init_engine(settings=settings) - await init_db(engine=engine) + try: + await init_db(engine=engine) - async with get_db() as session: - assert isinstance(session, AsyncSession) - result = await session.execute(text("SELECT 1")) - assert result.scalar_one() == 1 + async with get_db() as session: + assert isinstance(session, AsyncSession) + result = await session.execute(text("SELECT 1")) + assert result.scalar_one() == 1 + finally: + await engine.dispose() @pytest.mark.anyio async def test_commits_on_success(self, settings: AppSettings) -> None: """A value inserted inside get_db persists and is visible from a new session.""" engine = init_engine(settings=settings) - await init_db(engine=engine) - - row_id = _uuid.uuid4() - async with get_db() as session: - await session.execute( - text("INSERT INTO events (id, event_type, details) VALUES (:id, 'test.event', '{}')"), - {"id": str(row_id)}, - ) - await session.commit() - - # Open a fresh session to confirm persistence after commit. - async with get_db() as verify: - result = await verify.execute( - text("SELECT event_type FROM events WHERE id = :id"), - {"id": str(row_id)}, - ) - assert result.scalar_one() == "test.event" + try: + await init_db(engine=engine) + + row_id = _uuid.uuid4() + async with get_db() as session: + await session.execute( + text("INSERT INTO events (id, event_type, details) VALUES (:id, 'test.event', '{}')"), + {"id": str(row_id)}, + ) + await session.commit() + + # Open a fresh session to confirm persistence after commit. + async with get_db() as verify: + result = await verify.execute( + text("SELECT event_type FROM events WHERE id = :id"), + {"id": str(row_id)}, + ) + assert result.scalar_one() == "test.event" + finally: + await engine.dispose() @pytest.mark.anyio async def test_rollback_on_exception(self, settings: AppSettings) -> None: """An exception inside get_db rolls back so the insert is lost.""" engine = init_engine(settings=settings) - await init_db(engine=engine) - - row_id = _uuid.uuid4() try: - async with get_db() as session: - await session.execute( - text("INSERT INTO events (id, event_type, details) VALUES (:id, 'rollback.event', '{}')"), + await init_db(engine=engine) + + row_id = _uuid.uuid4() + try: + async with get_db() as session: + await session.execute( + text("INSERT INTO events (id, event_type, details) VALUES (:id, 'rollback.event', '{}')"), + {"id": str(row_id)}, + ) + raise ValueError("boom") + except ValueError: + pass + + # Open a fresh session to confirm the row was rolled back. + async with get_db() as verify: + result = await verify.execute( + text("SELECT id FROM events WHERE id = :id"), {"id": str(row_id)}, ) - raise ValueError("boom") - except ValueError: - pass - - # Open a fresh session to confirm the row was rolled back. - async with get_db() as verify: - result = await verify.execute( - text("SELECT id FROM events WHERE id = :id"), - {"id": str(row_id)}, - ) - assert result.scalar_one_or_none() is None + assert result.scalar_one_or_none() is None + finally: + await engine.dispose() # --------------------------------------------------------------------------- @@ -125,15 +134,19 @@ class TestInitDb: @pytest.mark.anyio async def test_creates_all_tables(self, settings: AppSettings) -> None: engine = init_engine(settings=settings) - await init_db(engine=engine) + try: + await init_db(engine=engine) - async with engine.connect() as conn: - result = await conn.execute( - text("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") - ) - table_names = [row[0] for row in result.fetchall()] + async with engine.connect() as conn: + result = await conn.execute( + text("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") + ) + table_names = [row[0] for row in result.fetchall()] + finally: + await engine.dispose() - # The three concrete model tables must all exist. + # The concrete model tables must all exist. + assert "api_keys" in table_names assert "certificates" in table_names assert "events" in table_names assert "renewal_attempts" in table_names diff --git a/tests/test_health.py b/tests/test_health.py index 51f69a1..3fadde6 100644 --- a/tests/test_health.py +++ b/tests/test_health.py @@ -3,7 +3,7 @@ from __future__ import annotations import pytest -from httpx import AsyncClient +from httpx2 import AsyncClient @pytest.mark.anyio From cb9f2a8e5a09bb0addec361732e046d6ca016aae Mon Sep 17 00:00:00 2001 From: Streaky Date: Thu, 2 Jul 2026 15:34:42 +0100 Subject: [PATCH 13/26] phase 6 --- acme_api/deployer.py | 237 +++++++++++++++++++++++++++++++++++++++++ tests/test_deployer.py | 161 ++++++++++++++++++++++++++++ 2 files changed, 398 insertions(+) create mode 100644 acme_api/deployer.py create mode 100644 tests/test_deployer.py diff --git a/acme_api/deployer.py b/acme_api/deployer.py new file mode 100644 index 0000000..7666e3d --- /dev/null +++ b/acme_api/deployer.py @@ -0,0 +1,237 @@ +"""Atomic certificate artifact deployment.""" + +from __future__ import annotations + +import dataclasses as dc +import json +import os +import shutil +import tempfile +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from acme_api.backend.dataclasses import CertExpiry, IssuanceResult + +CERT_FILE_NAME = "cert.pem" +CHAIN_FILE_NAME = "chain.pem" +FULLCHAIN_FILE_NAME = "fullchain.pem" +PRIVKEY_FILE_NAME = "privkey.pem" +METADATA_FILE_NAME = "metadata.json" + + +@dc.dataclass(frozen=True) +class DeploymentPaths: + """Filesystem paths written for a deployed certificate.""" + + directory: Path + cert_path: Path + chain_path: Path + fullchain_path: Path + privkey_path: Path + metadata_path: Path + + +@dc.dataclass(frozen=True) +class DeploymentMetadata: + """Metadata serialized next to deployed certificate files.""" + + primary_domain: str + domains: list[str] + expires_at: datetime + issuer: str | None = None + source_cert_path: str | None = None + source_chain_path: str | None = None + source_fullchain_path: str | None = None + + def to_json_dict(self) -> dict[str, Any]: + """Return a JSON-serializable metadata dictionary.""" + expires_at = self.expires_at + if expires_at.tzinfo is None: + expires_at = expires_at.replace(tzinfo=timezone.utc) + return { + "primary_domain": self.primary_domain, + "domains": self.domains, + "expires_at": expires_at.astimezone(timezone.utc).isoformat(), + "issuer": self.issuer, + "source_paths": { + "cert": self.source_cert_path, + "chain": self.source_chain_path, + "fullchain": self.source_fullchain_path, + }, + } + + +class DeploymentError(Exception): + """Raised when certificate artifacts cannot be deployed safely.""" + + +def deploy_issuance_result( + result: IssuanceResult, + deployment_root: Path, + *, + permissions_cert: int = 0o644, + permissions_key: int = 0o600, + issuer: str | None = None, +) -> DeploymentPaths: + """Deploy files from an ACME issuance or renewal result. + + Args: + result: Backend result containing source artifact paths and issued domains. + deployment_root: Root directory such as ``/certificates``. + permissions_cert: POSIX mode for public certificate artifacts. + permissions_key: POSIX mode for the private key. + issuer: Optional issuer string to include in metadata. + + Returns: + Paths to the deployed certificate artifacts. + + Raises: + DeploymentError: If domains are missing, unsafe, or source files are absent. + """ + cert = result.cert + metadata = DeploymentMetadata( + primary_domain=_primary_domain(result.domains), + domains=list(result.domains), + expires_at=cert.expires_at, + issuer=issuer, + source_cert_path=cert.cert_path, + source_chain_path=cert.chain_path, + source_fullchain_path=cert.fullchain_path, + ) + return deploy_certificate_artifacts( + cert=cert, + domains=result.domains, + deployment_root=deployment_root, + metadata=metadata, + permissions_cert=permissions_cert, + permissions_key=permissions_key, + ) + + +def deploy_certificate_artifacts( # pylint: disable=too-many-arguments + *, + cert: CertExpiry, + domains: list[str], + deployment_root: Path, + metadata: DeploymentMetadata | None = None, + permissions_cert: int = 0o644, + permissions_key: int = 0o600, +) -> DeploymentPaths: + """Atomically deploy certificate files under the primary domain directory.""" + primary_domain = _primary_domain(domains) + target_dir = deployment_root / _safe_domain_dir_name(primary_domain) + target_dir.mkdir(parents=True, exist_ok=True) + + source_paths = { + CERT_FILE_NAME: Path(cert.cert_path), + CHAIN_FILE_NAME: Path(cert.chain_path), + FULLCHAIN_FILE_NAME: Path(cert.fullchain_path), + PRIVKEY_FILE_NAME: Path(cert.privkey_path), + } + _validate_source_files(source_paths) + + if metadata is None: + metadata = DeploymentMetadata( + primary_domain=primary_domain, + domains=list(domains), + expires_at=cert.expires_at, + source_cert_path=cert.cert_path, + source_chain_path=cert.chain_path, + source_fullchain_path=cert.fullchain_path, + ) + + temp_dir = Path(tempfile.mkdtemp(prefix=".deploy-", dir=target_dir)) + try: + for file_name, source_path in source_paths.items(): + mode = permissions_key if file_name == PRIVKEY_FILE_NAME else permissions_cert + _copy_fsync_chmod(source_path, temp_dir / f"{file_name}.tmp", mode) + + metadata_bytes = json.dumps( + metadata.to_json_dict(), + indent=2, + sort_keys=True, + ).encode("utf-8") + _write_fsync_chmod( + temp_dir / f"{METADATA_FILE_NAME}.tmp", + metadata_bytes, + permissions_cert, + ) + _fsync_directory(temp_dir) + + for file_name in ( + CERT_FILE_NAME, + CHAIN_FILE_NAME, + FULLCHAIN_FILE_NAME, + PRIVKEY_FILE_NAME, + METADATA_FILE_NAME, + ): + os.replace(temp_dir / f"{file_name}.tmp", target_dir / file_name) + _fsync_directory(target_dir) + except OSError as exc: + raise DeploymentError(f"failed to deploy certificate artifacts: {exc}") from exc + finally: + shutil.rmtree(temp_dir, ignore_errors=True) + + return DeploymentPaths( + directory=target_dir, + cert_path=target_dir / CERT_FILE_NAME, + chain_path=target_dir / CHAIN_FILE_NAME, + fullchain_path=target_dir / FULLCHAIN_FILE_NAME, + privkey_path=target_dir / PRIVKEY_FILE_NAME, + metadata_path=target_dir / METADATA_FILE_NAME, + ) + + +def _primary_domain(domains: list[str]) -> str: + """Return the SAN primary domain.""" + if not domains: + raise DeploymentError("at least one domain is required for deployment") + return domains[0] + + +def _safe_domain_dir_name(domain: str) -> str: + """Return a safe single path segment for a domain name.""" + if "/" in domain or "\\" in domain or domain in {"", ".", ".."}: + raise DeploymentError(f"unsafe primary domain for deployment: {domain!r}") + if domain.startswith("*."): + return f"wildcard.{domain[2:]}" + return domain + + +def _validate_source_files(source_paths: dict[str, Path]) -> None: + """Ensure all source artifact paths exist and are regular files.""" + missing = [ + f"{name}: {path}" + for name, path in source_paths.items() + if not path.is_file() + ] + if missing: + raise DeploymentError( + "missing certificate source artifact(s): " + ", ".join(missing) + ) + + +def _copy_fsync_chmod(source: Path, destination: Path, mode: int) -> None: + """Copy a source file to destination, flush it, fsync it, and chmod it.""" + with source.open("rb") as src: + _write_fsync_chmod(destination, src.read(), mode) + + +def _write_fsync_chmod(destination: Path, data: bytes, mode: int) -> None: + """Write bytes durably to a destination path with the requested mode.""" + with destination.open("wb") as file_handle: + file_handle.write(data) + file_handle.flush() + os.fsync(file_handle.fileno()) + os.chmod(destination, mode) + + +def _fsync_directory(directory: Path) -> None: + """Flush directory metadata for POSIX filesystems.""" + flags = getattr(os, "O_DIRECTORY", 0) + fd = os.open(directory, os.O_RDONLY | flags) + try: + os.fsync(fd) + finally: + os.close(fd) diff --git a/tests/test_deployer.py b/tests/test_deployer.py new file mode 100644 index 0000000..bc2d412 --- /dev/null +++ b/tests/test_deployer.py @@ -0,0 +1,161 @@ +"""Tests for atomic certificate filesystem deployment.""" + +from __future__ import annotations + +import json +import pathlib +import stat +from datetime import datetime, timezone +from unittest.mock import patch + +import pytest + +from acme_api.backend.dataclasses import CertExpiry, IssuanceResult +from acme_api.deployer import ( + DeploymentError, + deploy_certificate_artifacts, + deploy_issuance_result, +) + + +def _write_sources(tmp_path: pathlib.Path) -> CertExpiry: + source_dir = tmp_path / "acmesh" + source_dir.mkdir() + files = { + "cert.pem": b"server-cert\n", + "chain.pem": b"ca-chain\n", + "fullchain.pem": b"server-cert\nca-chain\n", + "privkey.pem": b"private-key\n", + } + for file_name, content in files.items(): + (source_dir / file_name).write_bytes(content) + + return CertExpiry( + cert_path=str(source_dir / "cert.pem"), + chain_path=str(source_dir / "chain.pem"), + fullchain_path=str(source_dir / "fullchain.pem"), + privkey_path=str(source_dir / "privkey.pem"), + expires_at=datetime(2026, 12, 31, 23, 59, tzinfo=timezone.utc), + ) + + +def _mode(path: pathlib.Path) -> int: + return stat.S_IMODE(path.stat().st_mode) + + +def test_deploy_issuance_result_writes_expected_layout(tmp_path: pathlib.Path) -> None: + """Deployment writes all expected files under the primary domain.""" + cert = _write_sources(tmp_path) + result = IssuanceResult( + account_key_path="/acmesh/acct.key", + cert=cert, + domains=["example.com", "www.example.com"], + ) + + deployed = deploy_issuance_result(result, tmp_path / "certificates", issuer="test-ca") + + assert deployed.directory == tmp_path / "certificates" / "example.com" + assert deployed.cert_path.read_bytes() == b"server-cert\n" + assert deployed.chain_path.read_bytes() == b"ca-chain\n" + assert deployed.fullchain_path.read_bytes() == b"server-cert\nca-chain\n" + assert deployed.privkey_path.read_bytes() == b"private-key\n" + + metadata = json.loads(deployed.metadata_path.read_text(encoding="utf-8")) + assert metadata["primary_domain"] == "example.com" + assert metadata["domains"] == ["example.com", "www.example.com"] + assert metadata["issuer"] == "test-ca" + assert metadata["expires_at"] == "2026-12-31T23:59:00+00:00" + + +def test_deploy_sets_certificate_and_key_permissions(tmp_path: pathlib.Path) -> None: + """Certificate files are public-readable and private keys are restricted.""" + cert = _write_sources(tmp_path) + + deployed = deploy_certificate_artifacts( + cert=cert, + domains=["secure.example.com"], + deployment_root=tmp_path / "certificates", + ) + + assert _mode(deployed.cert_path) == 0o644 + assert _mode(deployed.chain_path) == 0o644 + assert _mode(deployed.fullchain_path) == 0o644 + assert _mode(deployed.metadata_path) == 0o644 + assert _mode(deployed.privkey_path) == 0o600 + + +def test_wildcard_primary_domain_uses_safe_directory_name(tmp_path: pathlib.Path) -> None: + """Wildcard domains are deployed to a portable directory name.""" + cert = _write_sources(tmp_path) + + deployed = deploy_certificate_artifacts( + cert=cert, + domains=["*.example.com"], + deployment_root=tmp_path / "certificates", + ) + + assert deployed.directory.name == "wildcard.example.com" + + +def test_missing_source_file_raises(tmp_path: pathlib.Path) -> None: + """Deployment fails before writing when any source artifact is missing.""" + cert = _write_sources(tmp_path) + pathlib.Path(cert.chain_path).unlink() + + with pytest.raises(DeploymentError, match="missing certificate source"): + deploy_certificate_artifacts( + cert=cert, + domains=["example.com"], + deployment_root=tmp_path / "certificates", + ) + + +def test_unsafe_primary_domain_raises(tmp_path: pathlib.Path) -> None: + """Primary domain must not escape the deployment root.""" + cert = _write_sources(tmp_path) + + with pytest.raises(DeploymentError, match="unsafe primary domain"): + deploy_certificate_artifacts( + cert=cert, + domains=["../example.com"], + deployment_root=tmp_path / "certificates", + ) + + +def test_failed_deploy_preserves_existing_files(tmp_path: pathlib.Path) -> None: + """A write failure leaves previously deployed files untouched.""" + cert = _write_sources(tmp_path) + deployment_root = tmp_path / "certificates" + first = deploy_certificate_artifacts( + cert=cert, + domains=["example.com"], + deployment_root=deployment_root, + ) + first.cert_path.write_bytes(b"existing-cert\n") + + with patch("acme_api.deployer.os.replace", side_effect=OSError("boom")): + with pytest.raises(DeploymentError, match="failed to deploy"): + deploy_certificate_artifacts( + cert=cert, + domains=["example.com"], + deployment_root=deployment_root, + ) + + assert first.cert_path.read_bytes() == b"existing-cert\n" + assert not any(path.name.startswith(".deploy-") for path in first.directory.iterdir()) + + +def test_custom_permissions_are_honored(tmp_path: pathlib.Path) -> None: + """Deployment accepts configured certificate and key modes.""" + cert = _write_sources(tmp_path) + + deployed = deploy_certificate_artifacts( + cert=cert, + domains=["mode.example.com"], + deployment_root=tmp_path / "certificates", + permissions_cert=0o640, + permissions_key=0o400, + ) + + assert _mode(deployed.cert_path) == 0o640 + assert _mode(deployed.privkey_path) == 0o400 From 98b2de38ba5c2dc3779dc0e9fc017d68346913af Mon Sep 17 00:00:00 2001 From: Streaky Date: Thu, 2 Jul 2026 15:52:28 +0100 Subject: [PATCH 14/26] phases 7 and 8 --- acme_api/config.py | 10 + acme_api/main.py | 39 ++- acme_api/models/__init__.py | 3 + acme_api/models/webhook.py | 44 +++ acme_api/scheduler.py | 254 +++++++++++++++++ acme_api/webhooks.py | 194 +++++++++++++ .../versions/8760d3a7fed0_initial_schema.py | 12 + config.example.yaml | 6 + tests/test_alembic.py | 8 +- tests/test_db.py | 1 + tests/test_scheduler.py | 262 ++++++++++++++++++ tests/test_webhooks.py | 183 ++++++++++++ 12 files changed, 1010 insertions(+), 6 deletions(-) create mode 100644 acme_api/models/webhook.py create mode 100644 acme_api/scheduler.py create mode 100644 acme_api/webhooks.py create mode 100644 tests/test_scheduler.py create mode 100644 tests/test_webhooks.py diff --git a/acme_api/config.py b/acme_api/config.py index 61dc1bf..014b5be 100644 --- a/acme_api/config.py +++ b/acme_api/config.py @@ -90,6 +90,15 @@ class RenewalConfig(StrictConfigModel): check_interval_hours: int = Field(default=24, ge=1) window_days: int = Field(default=30, ge=1) max_retries: int = Field(default=3, ge=0) + shutdown_timeout_seconds: int = Field(default=30, ge=1) + + +class WebhookDeliveryConfig(StrictConfigModel): + """Outbound webhook delivery configuration.""" + + timeout_seconds: float = Field(default=5.0, gt=0) + max_retries: int = Field(default=3, ge=0) + backoff_seconds: float = Field(default=1.0, ge=0) class AppSettings(StrictConfigModel): @@ -109,6 +118,7 @@ class AppSettings(StrictConfigModel): deployment: DeploymentConfig = Field(default_factory=DeploymentConfig) acme: AcmeConfig = Field(default_factory=AcmeConfig) renewal: RenewalConfig = Field(default_factory=RenewalConfig) + webhooks: WebhookDeliveryConfig = Field(default_factory=WebhookDeliveryConfig) dns_providers: list[DnsProviderConfig] = Field(default_factory=list) acme_accounts: list[AcmeAccountConfig] = Field(default_factory=list) api_keys: dict[str, str] = Field( diff --git a/acme_api/main.py b/acme_api/main.py index b15ed7d..4ef8b8b 100644 --- a/acme_api/main.py +++ b/acme_api/main.py @@ -8,6 +8,7 @@ import logging from contextlib import asynccontextmanager +from pathlib import Path from typing import AsyncGenerator import uvicorn @@ -15,11 +16,14 @@ from fastapi.responses import JSONResponse from acme_api.auth.bootstrap import seed_initial_keys +from acme_api.backend.acmesh_backend import AcmeShBackend, _AcmeShBackendConfig from acme_api.config import AppSettings, load_config, prepare_runtime_paths -from acme_api.db import get_db, init_db, init_engine +from acme_api.db import get_db, get_session_factory, init_db, init_engine from acme_api.logging import setup_logging from acme_api.middleware import RequestIdMiddleware from acme_api.routers import certificates_router, config_router, events_router +from acme_api.scheduler import RenewalScheduler +from acme_api.webhooks import WebhookDeliverySettings, WebhookDispatcher @asynccontextmanager @@ -52,10 +56,35 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]: "seeded api key | name=%s role=%s", key.name, key.role.value ) - yield - - await engine.dispose() - root_logger.info("acme.api shutting down") + renewal_scheduler = RenewalScheduler( + session_factory=get_session_factory(), + backend=AcmeShBackend( + _AcmeShBackendConfig( + binary_path=Path(settings.acme.binary_path), + home_dir=settings.acme.home_dir, + log_file=None, + force_renewal=False, + dnssleep_seconds=None, + ) + ), + config=settings.renewal, + webhook_dispatcher_factory=lambda session: WebhookDispatcher( + session, + WebhookDeliverySettings( + timeout_seconds=settings.webhooks.timeout_seconds, + max_retries=settings.webhooks.max_retries, + backoff_seconds=settings.webhooks.backoff_seconds, + ), + ), + ) + await renewal_scheduler.start() + + try: + yield + finally: + await renewal_scheduler.shutdown() + await engine.dispose() + root_logger.info("acme.api shutting down") def create_app(settings: AppSettings | None = None) -> FastAPI: diff --git a/acme_api/models/__init__.py b/acme_api/models/__init__.py index 6fc4bdc..6f24c8d 100644 --- a/acme_api/models/__init__.py +++ b/acme_api/models/__init__.py @@ -9,6 +9,7 @@ CertificateStatus, Event, RenewalAttempt, + WebhookConfig, ) """ @@ -19,6 +20,7 @@ from acme_api.models.certificate import Certificate, CertificateStatus from acme_api.models.event import Event from acme_api.models.renewal_attempt import RenewalAttempt +from acme_api.models.webhook import WebhookConfig __all__ = [ "APIKey", @@ -29,4 +31,5 @@ "Event", "RenewalAttempt", "TimestampMixin", + "WebhookConfig", ] diff --git a/acme_api/models/webhook.py b/acme_api/models/webhook.py new file mode 100644 index 0000000..3f4cb22 --- /dev/null +++ b/acme_api/models/webhook.py @@ -0,0 +1,44 @@ +"""Webhook configuration model.""" + +from __future__ import annotations + +import uuid as _uuid + +from sqlalchemy import JSON, Boolean, String, text +from sqlalchemy.orm import Mapped, mapped_column + +from acme_api.models.base import Base, TimestampMixin + + +class WebhookConfig(Base, TimestampMixin): + """Row describing an outbound webhook subscription.""" + + __tablename__ = "webhook_configs" + + id: Mapped[_uuid.UUID] = mapped_column( + primary_key=True, + default=_uuid.uuid4, + doc="Unique identifier for the webhook subscription.", + ) + url: Mapped[str] = mapped_column( + String(2048), + nullable=False, + doc="Destination URL for webhook deliveries.", + ) + events: Mapped[list[str]] = mapped_column( + JSON, + nullable=False, + doc="Event types this webhook receives. Use ['*'] for all events.", + ) + secret: Mapped[str] = mapped_column( + String(255), + nullable=False, + doc="Shared secret used to sign webhook payloads.", + ) + enabled: Mapped[bool] = mapped_column( + Boolean, + default=True, + server_default=text("1"), + nullable=False, + doc="Whether this webhook receives deliveries.", + ) diff --git a/acme_api/scheduler.py b/acme_api/scheduler.py new file mode 100644 index 0000000..310db10 --- /dev/null +++ b/acme_api/scheduler.py @@ -0,0 +1,254 @@ +"""Automatic certificate renewal scheduler.""" + +from __future__ import annotations + +import datetime as dt +import uuid +from collections.abc import Callable +from typing import Awaitable + +from apscheduler.schedulers.asyncio import ( # type: ignore[import-untyped] + AsyncIOScheduler, +) +from apscheduler.triggers.date import DateTrigger # type: ignore[import-untyped] +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from acme_api.backend.acmesh_backend import AcmeShError, TransientAcmeShError +from acme_api.backend.protocol import AcmeBackend +from acme_api.config import RenewalConfig +from acme_api.models.certificate import Certificate, CertificateStatus +from acme_api.models.event import Event +from acme_api.models.renewal_attempt import RenewalAttempt +from acme_api.webhooks import WebhookDispatcher + +WebhookDispatcherFactory = Callable[[AsyncSession], WebhookDispatcher] + + +class RenewalScheduler: + """Schedules and executes automatic certificate renewals.""" + + def __init__( + self, + session_factory: async_sessionmaker[AsyncSession], + backend: AcmeBackend, + config: RenewalConfig, + webhook_dispatcher_factory: WebhookDispatcherFactory | None = None, + scheduler: AsyncIOScheduler | None = None, + ) -> None: + self._session_factory = session_factory + self._backend = backend + self._config = config + self._webhook_dispatcher_factory = webhook_dispatcher_factory + self._scheduler = scheduler or AsyncIOScheduler(timezone=dt.timezone.utc) + + async def start(self) -> None: + """Start APScheduler and reconstruct renewal jobs from the database.""" + if not self._config.enabled: + return + if not self._scheduler.running: + self._scheduler.start() + await self.rebuild_jobs() + + async def shutdown(self) -> None: + """Stop the scheduler and wait for running jobs to finish.""" + if self._scheduler.running: + self._scheduler.shutdown(wait=True) + + async def rebuild_jobs(self) -> int: + """Schedule renewal jobs for all renewable certificates.""" + async with self._session_factory() as session: + result = await session.execute( + select(Certificate).where(Certificate.status == CertificateStatus.VALID) + ) + certificates = list(result.scalars().all()) + + for certificate in certificates: + self.schedule_certificate(certificate) + return len(certificates) + + def schedule_certificate(self, certificate: Certificate) -> dt.datetime | None: + """Schedule a renewal job for a certificate and return its run time.""" + run_at = next_renewal_run_time(certificate.expiry_date, self._config.window_days) + if run_at is None: + return None + + self._scheduler.add_job( + self.renew_certificate, + trigger=DateTrigger(run_date=run_at), + args=[certificate.id], + id=_job_id(certificate.id), + replace_existing=True, + misfire_grace_time=3600, + ) + return run_at + + async def renew_certificate(self, certificate_id: uuid.UUID) -> None: + """Run one renewal attempt for the latest certificate row state.""" + async with self._session_factory() as session: + certificate = await session.get(Certificate, certificate_id) + if certificate is None or certificate.status != CertificateStatus.VALID: + return + + certificate.status = CertificateStatus.RENEWING + await session.commit() + + try: + result = await self._backend.renew_certificate( + domains=certificate.domains, + force_renewal=False, + ) + except TransientAcmeShError as exc: + await self._record_failure( + session=session, + certificate=certificate, + error=exc, + transient=True, + ) + return + except AcmeShError as exc: + await self._record_failure( + session=session, + certificate=certificate, + error=exc, + transient=False, + ) + return + + certificate.expiry_date = result.cert.expires_at + certificate.status = CertificateStatus.VALID + session.add( + RenewalAttempt( + certificate_id=certificate.id, + status="success", + ) + ) + session.add( + Event( + event_type="certificate.renewed", + certificate_id=certificate.id, + details={ + "domains": certificate.domains, + "expires_at": result.cert.expires_at.isoformat(), + }, + ) + ) + await session.commit() + await self._dispatch_webhook(session, "certificate.renewed", certificate) + + self.schedule_certificate(certificate) + + async def _record_failure( + self, + *, + session: AsyncSession, + certificate: Certificate, + error: Exception, + transient: bool, + ) -> None: + """Record a failed renewal attempt and schedule retry if applicable.""" + attempts = await _attempt_count(session, certificate.id) + next_retry_at = None + if transient and attempts < self._config.max_retries: + next_retry_at = _retry_time(attempts) + certificate.status = CertificateStatus.VALID + self._scheduler.add_job( + self.renew_certificate, + trigger=DateTrigger(run_date=next_retry_at), + args=[certificate.id], + id=_retry_job_id(certificate.id), + replace_existing=True, + ) + else: + certificate.status = CertificateStatus.FAILED + + category = "transient" if transient else "terminal" + session.add( + RenewalAttempt( + certificate_id=certificate.id, + status="failed", + error_category=category, + error_details={"message": str(error)}, + next_retry_at=next_retry_at, + ) + ) + session.add( + Event( + event_type="certificate.failed", + certificate_id=certificate.id, + details={"category": category, "error": str(error)}, + ) + ) + await session.commit() + await self._dispatch_webhook(session, "certificate.failed", certificate) + + async def _dispatch_webhook( + self, + session: AsyncSession, + event_type: str, + certificate: Certificate, + ) -> None: + """Dispatch a lifecycle webhook when a dispatcher factory is configured.""" + if self._webhook_dispatcher_factory is None: + return + async with self._webhook_dispatcher_factory(session) as dispatcher: + await dispatcher.dispatch_certificate_event(event_type, certificate) + + +def next_renewal_run_time( + expiry_date: dt.datetime | None, + window_days: int, + now_factory: Callable[[], dt.datetime] | None = None, +) -> dt.datetime | None: + """Calculate when a certificate should be renewed.""" + if expiry_date is None: + return None + now = now_factory() if now_factory else dt.datetime.now(dt.timezone.utc) + expiry = _as_utc(expiry_date) + scheduled = expiry - dt.timedelta(days=window_days) + return now if scheduled <= now else scheduled + + +def build_scheduler_task( + renewal_scheduler: RenewalScheduler, +) -> Callable[[], Awaitable[None]]: + """Return a zero-argument coroutine useful for framework lifecycle hooks.""" + + async def _start() -> None: + await renewal_scheduler.start() + + return _start + + +def _job_id(certificate_id: uuid.UUID) -> str: + """Return the stable APScheduler job ID for a certificate.""" + return f"renew:{certificate_id}" + + +def _retry_job_id(certificate_id: uuid.UUID) -> str: + """Return the stable retry job ID for a certificate.""" + return f"renew-retry:{certificate_id}" + + +def _retry_time(previous_attempts: int) -> dt.datetime: + """Return the next retry time using exponential backoff.""" + delay_minutes = 2 ** max(previous_attempts, 0) + return dt.datetime.now(dt.timezone.utc) + dt.timedelta(minutes=delay_minutes) + + +async def _attempt_count(session: AsyncSession, certificate_id: uuid.UUID) -> int: + """Return the number of previous failed renewal attempts for a certificate.""" + result = await session.execute( + select(RenewalAttempt).where( + RenewalAttempt.certificate_id == certificate_id, + RenewalAttempt.status == "failed", + ) + ) + return len(result.scalars().all()) + + +def _as_utc(value: dt.datetime) -> dt.datetime: + """Normalize naive or aware datetimes to UTC.""" + if value.tzinfo is None: + return value.replace(tzinfo=dt.timezone.utc) + return value.astimezone(dt.timezone.utc) diff --git a/acme_api/webhooks.py b/acme_api/webhooks.py new file mode 100644 index 0000000..692e8b0 --- /dev/null +++ b/acme_api/webhooks.py @@ -0,0 +1,194 @@ +"""Webhook payload signing and delivery.""" + +from __future__ import annotations + +import asyncio +import dataclasses as dc +import hashlib +import hmac +import json +import uuid +from datetime import datetime, timezone +from typing import Any + +import httpx2 +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from acme_api.models.certificate import Certificate +from acme_api.models.event import Event +from acme_api.models.webhook import WebhookConfig + +SIGNATURE_HEADER = "X-Webhook-Signature" +EVENT_HEADER = "X-Webhook-Event" + + +@dc.dataclass(frozen=True) +class WebhookDeliverySettings: + """Runtime settings for outbound webhook delivery.""" + + timeout_seconds: float = 5.0 + max_retries: int = 3 + backoff_seconds: float = 1.0 + + +@dc.dataclass(frozen=True) +class WebhookPayload: + """Canonical webhook payload.""" + + event: str + certificate_name: str + domains: list[str] + expiry: datetime | None + certificate_id: uuid.UUID | None = None + details: dict[str, Any] = dc.field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + """Return a JSON-serializable payload.""" + expiry = self.expiry + if expiry and expiry.tzinfo is None: + expiry = expiry.replace(tzinfo=timezone.utc) + return { + "event": self.event, + "certificate_id": str(self.certificate_id) if self.certificate_id else None, + "certificate_name": self.certificate_name, + "expiry": expiry.astimezone(timezone.utc).isoformat() if expiry else None, + "domains": self.domains, + "details": self.details, + } + + +class WebhookDeliveryError(Exception): + """Raised when a webhook delivery exhausts all retry attempts.""" + + +def encode_payload(payload: WebhookPayload) -> bytes: + """Serialize a webhook payload using stable JSON formatting.""" + return json.dumps( + payload.to_dict(), + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + + +def sign_payload(secret: str, payload_body: bytes) -> str: + """Return the HMAC-SHA256 signature header value for a payload.""" + digest = hmac.new(secret.encode("utf-8"), payload_body, hashlib.sha256).hexdigest() + return f"sha256={digest}" + + +def payload_for_certificate( + event_type: str, + certificate: Certificate, + details: dict[str, Any] | None = None, +) -> WebhookPayload: + """Build a webhook payload from a certificate row.""" + return WebhookPayload( + event=event_type, + certificate_id=certificate.id, + certificate_name=certificate.name, + domains=list(certificate.domains), + expiry=certificate.expiry_date, + details=details or {}, + ) + + +class WebhookDispatcher: + """Delivers lifecycle events to configured webhook subscriptions.""" + + def __init__( + self, + session: AsyncSession, + settings: WebhookDeliverySettings | None = None, + client: httpx2.AsyncClient | None = None, + ) -> None: + self._session = session + self._settings = settings or WebhookDeliverySettings() + self._client = client + self._owns_client = client is None + + async def __aenter__(self) -> "WebhookDispatcher": + if self._client is None: + self._client = httpx2.AsyncClient(timeout=self._settings.timeout_seconds) + return self + + async def __aexit__(self, *_exc_info: object) -> None: + if self._owns_client and self._client is not None: + await self._client.aclose() + + async def dispatch_certificate_event( + self, + event_type: str, + certificate: Certificate, + details: dict[str, Any] | None = None, + ) -> int: + """Deliver a certificate lifecycle event to subscribed webhooks.""" + payload = payload_for_certificate(event_type, certificate, details) + return await self.dispatch(payload) + + async def dispatch(self, payload: WebhookPayload) -> int: + """Deliver a payload to all enabled subscribers and return delivery count.""" + configs = await self._matching_configs(payload.event) + delivered = 0 + for config in configs: + try: + await self._deliver_to_config(config, payload) + delivered += 1 + except WebhookDeliveryError as exc: + self._session.add( + Event( + event_type="webhook.delivery_failed", + certificate_id=payload.certificate_id, + details={ + "webhook_id": str(config.id), + "url": config.url, + "event": payload.event, + "error": str(exc), + }, + ) + ) + await self._session.commit() + return delivered + + async def _matching_configs(self, event_type: str) -> list[WebhookConfig]: + """Return enabled webhook configs subscribed to an event.""" + result = await self._session.execute( + select(WebhookConfig).where(WebhookConfig.enabled.is_(True)) + ) + return [ + config + for config in result.scalars().all() + if "*" in config.events or event_type in config.events + ] + + async def _deliver_to_config( + self, + config: WebhookConfig, + payload: WebhookPayload, + ) -> None: + """Deliver a single webhook subscription with retry.""" + if self._client is None: + raise RuntimeError("WebhookDispatcher must be used as an async context manager") + + body = encode_payload(payload) + headers = { + "Content-Type": "application/json", + EVENT_HEADER: payload.event, + SIGNATURE_HEADER: sign_payload(config.secret, body), + } + attempts = self._settings.max_retries + 1 + last_error = "" + + for attempt_number in range(1, attempts + 1): + try: + response = await self._client.post(config.url, content=body, headers=headers) + if 200 <= response.status_code < 300: + return + last_error = f"HTTP {response.status_code}" + except httpx2.HTTPError as exc: + last_error = str(exc) + + if attempt_number < attempts and self._settings.backoff_seconds > 0: + await asyncio.sleep(self._settings.backoff_seconds * attempt_number) + + raise WebhookDeliveryError(last_error or "delivery failed") diff --git a/alembic/versions/8760d3a7fed0_initial_schema.py b/alembic/versions/8760d3a7fed0_initial_schema.py index b889c93..466449c 100644 --- a/alembic/versions/8760d3a7fed0_initial_schema.py +++ b/alembic/versions/8760d3a7fed0_initial_schema.py @@ -77,6 +77,17 @@ def upgrade() -> None: with op.batch_alter_table('renewal_attempts', schema=None) as batch_op: batch_op.create_index(batch_op.f('ix_renewal_attempts_certificate_id'), ['certificate_id'], unique=False) + op.create_table('webhook_configs', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('url', sa.String(length=2048), nullable=False), + sa.Column('events', sa.JSON(), nullable=False), + sa.Column('secret', sa.String(length=255), nullable=False), + sa.Column('enabled', sa.Boolean(), server_default=sa.text('1'), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### @@ -86,6 +97,7 @@ def downgrade() -> None: with op.batch_alter_table('renewal_attempts', schema=None) as batch_op: batch_op.drop_index(batch_op.f('ix_renewal_attempts_certificate_id')) + op.drop_table('webhook_configs') op.drop_table('renewal_attempts') with op.batch_alter_table('events', schema=None) as batch_op: batch_op.drop_index(batch_op.f('ix_events_event_type')) diff --git a/config.example.yaml b/config.example.yaml index dedac2b..fb934cb 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -23,6 +23,12 @@ renewal: check_interval_hours: 24 window_days: 30 max_retries: 3 + shutdown_timeout_seconds: 30 + +webhooks: + timeout_seconds: 5.0 + max_retries: 3 + backoff_seconds: 1.0 # Optional bootstrap API keys. Values are raw secrets read once and stored as # hashes in SQLite if the named bootstrap key does not already exist. diff --git a/tests/test_alembic.py b/tests/test_alembic.py index 392b7fe..2a1779f 100644 --- a/tests/test_alembic.py +++ b/tests/test_alembic.py @@ -91,7 +91,13 @@ class = StreamHandler finally: engine.dispose() - expected_tables = {"certificates", "events", "renewal_attempts", "alembic_version"} + expected_tables = { + "alembic_version", + "certificates", + "events", + "renewal_attempts", + "webhook_configs", + } assert expected_tables.issubset(table_names), ( f"Missing tables {expected_tables - table_names}; found {table_names}" ) diff --git a/tests/test_db.py b/tests/test_db.py index 7f2ae2b..0f3d5d1 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -150,3 +150,4 @@ async def test_creates_all_tables(self, settings: AppSettings) -> None: assert "certificates" in table_names assert "events" in table_names assert "renewal_attempts" in table_names + assert "webhook_configs" in table_names diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py new file mode 100644 index 0000000..81d2682 --- /dev/null +++ b/tests/test_scheduler.py @@ -0,0 +1,262 @@ +"""Tests for renewal scheduling and execution.""" + +from __future__ import annotations + +import datetime as dt +from collections.abc import AsyncGenerator +from pathlib import Path +from typing import Any + +import pytest +from apscheduler.schedulers.asyncio import ( # type: ignore[import-untyped] + AsyncIOScheduler, +) +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from acme_api.backend.acmesh_backend import TerminalAcmeShError, TransientAcmeShError +from acme_api.backend.dataclasses import AccountInfo, CertExpiry, IssuanceResult +from acme_api.backend.protocol import ChallengeMethod +from acme_api.config import AppSettings, DatabaseConfig, DeploymentConfig, RenewalConfig +from acme_api.db import get_session_factory, init_db, init_engine +from acme_api.models.certificate import Certificate, CertificateStatus +from acme_api.models.event import Event +from acme_api.models.renewal_attempt import RenewalAttempt +from acme_api.scheduler import RenewalScheduler, next_renewal_run_time + + +class RecordingBackend: + """Small backend test double for renewal outcomes.""" + + def __init__(self) -> None: + self.calls = 0 + self.error: Exception | None = None + + async def register_account(self, email: str, server_url: str) -> AccountInfo: + """Return deterministic account info.""" + return AccountInfo( + key_path="/acmesh/acct.key", + email=email, + server_url=server_url, + ) + + async def issue_certificate( + self, + domains: list[str], + method: ChallengeMethod, + challenge_params: dict[str, Any], + account_key_path: str | None = None, + ) -> IssuanceResult: + """Return deterministic issuance info.""" + del method, challenge_params + return IssuanceResult( + account_key_path=account_key_path or "/acmesh/acct.key", + cert=_cert_expiry(), + domains=domains, + ) + + async def renew_certificate( + self, domains: list[str], force_renewal: bool = False + ) -> IssuanceResult: + """Return a successful renewal or raise the configured error.""" + del force_renewal + self.calls += 1 + if self.error is not None: + raise self.error + expires_at = dt.datetime.now(dt.timezone.utc) + dt.timedelta(days=90) + return IssuanceResult( + account_key_path="/acmesh/acct.key", + cert=_cert_expiry(expires_at), + domains=domains, + ) + + async def get_certificate_expiry(self, cert_path: str) -> CertExpiry: + """Return deterministic expiry info.""" + result = _cert_expiry() + return CertExpiry( + cert_path=cert_path, + privkey_path=result.privkey_path, + chain_path=result.chain_path, + fullchain_path=result.fullchain_path, + expires_at=result.expires_at, + ) + + +def _cert_expiry(expires_at: dt.datetime | None = None) -> CertExpiry: + """Return a deterministic cert expiry object.""" + return CertExpiry( + cert_path="/acmesh/cert.pem", + privkey_path="/acmesh/privkey.pem", + chain_path="/acmesh/chain.pem", + fullchain_path="/acmesh/fullchain.pem", + expires_at=expires_at + or dt.datetime.now(dt.timezone.utc) + dt.timedelta(days=90), + ) + + +@pytest.fixture() +async def session_factory( + tmp_path: Path, +) -> AsyncGenerator[async_sessionmaker[AsyncSession], None]: + settings = AppSettings( + database=DatabaseConfig(url=f"sqlite+aiosqlite:///{tmp_path}/test.db"), + deployment=DeploymentConfig(directory=tmp_path), + ) + engine = init_engine(settings) + try: + await init_db(engine) + yield get_session_factory() + finally: + await engine.dispose() + + +async def _create_certificate( + session_factory: async_sessionmaker[AsyncSession], + *, + expiry: dt.datetime | None = None, +) -> Certificate: + async with session_factory() as session: + certificate = Certificate( + name="renew-me", + domains=["example.com"], + acme_account_ref="le", + dns_provider_ref="cf", + expiry_date=expiry or dt.datetime.now(dt.timezone.utc), + status=CertificateStatus.VALID, + ) + session.add(certificate) + await session.commit() + await session.refresh(certificate) + return certificate + + +def test_next_renewal_run_time_uses_window() -> None: + """Renewal is scheduled at expiry minus configured window.""" + now = dt.datetime(2026, 1, 1, tzinfo=dt.timezone.utc) + expiry = dt.datetime(2026, 3, 1, tzinfo=dt.timezone.utc) + + run_at = next_renewal_run_time(expiry, 30, now_factory=lambda: now) + + assert run_at == dt.datetime(2026, 1, 30, tzinfo=dt.timezone.utc) + + +def test_next_renewal_run_time_is_immediate_inside_window() -> None: + """Certs already inside the renewal window run immediately.""" + now = dt.datetime(2026, 1, 20, tzinfo=dt.timezone.utc) + expiry = dt.datetime(2026, 2, 1, tzinfo=dt.timezone.utc) + + run_at = next_renewal_run_time(expiry, 30, now_factory=lambda: now) + + assert run_at == now + + +@pytest.mark.anyio +async def test_rebuild_jobs_schedules_valid_certificates( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + """Startup reconstruction creates one APScheduler job per valid cert.""" + certificate = await _create_certificate(session_factory) + scheduler = AsyncIOScheduler(timezone=dt.timezone.utc) + renewal_scheduler = RenewalScheduler( + session_factory=session_factory, + backend=RecordingBackend(), + config=RenewalConfig(), + scheduler=scheduler, + ) + + count = await renewal_scheduler.rebuild_jobs() + + assert count == 1 + assert scheduler.get_job(f"renew:{certificate.id}") is not None + + +@pytest.mark.anyio +async def test_successful_renewal_updates_status_and_records_attempt( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + """Successful renewal updates expiry, attempt history, and audit events.""" + certificate = await _create_certificate(session_factory) + backend = RecordingBackend() + renewal_scheduler = RenewalScheduler( + session_factory=session_factory, + backend=backend, + config=RenewalConfig(), + scheduler=AsyncIOScheduler(timezone=dt.timezone.utc), + ) + + await renewal_scheduler.renew_certificate(certificate.id) + + async with session_factory() as session: + refreshed = await session.get(Certificate, certificate.id) + attempts = ( + await session.execute(select(RenewalAttempt)) + ).scalars().all() + events = ( + await session.execute(select(Event).where(Event.event_type == "certificate.renewed")) + ).scalars().all() + + assert backend.calls == 1 + assert refreshed is not None + assert refreshed.status == CertificateStatus.VALID + assert refreshed.expiry_date is not None + assert len(attempts) == 1 + assert attempts[0].status == "success" + assert len(events) == 1 + + +@pytest.mark.anyio +async def test_transient_failure_records_retry( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + """Transient backend failures keep the cert valid and schedule retry.""" + certificate = await _create_certificate(session_factory) + backend = RecordingBackend() + backend.error = TransientAcmeShError("dns not ready") + scheduler = AsyncIOScheduler(timezone=dt.timezone.utc) + renewal_scheduler = RenewalScheduler( + session_factory=session_factory, + backend=backend, + config=RenewalConfig(max_retries=1), + scheduler=scheduler, + ) + + await renewal_scheduler.renew_certificate(certificate.id) + + async with session_factory() as session: + refreshed = await session.get(Certificate, certificate.id) + attempt = (await session.execute(select(RenewalAttempt))).scalar_one() + + assert refreshed is not None + assert refreshed.status == CertificateStatus.VALID + assert attempt.status == "failed" + assert attempt.error_category == "transient" + assert attempt.next_retry_at is not None + assert scheduler.get_job(f"renew-retry:{certificate.id}") is not None + + +@pytest.mark.anyio +async def test_terminal_failure_marks_certificate_failed( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + """Terminal backend failures mark the cert failed without retry.""" + certificate = await _create_certificate(session_factory) + backend = RecordingBackend() + backend.error = TerminalAcmeShError("account invalid") + scheduler = AsyncIOScheduler(timezone=dt.timezone.utc) + renewal_scheduler = RenewalScheduler( + session_factory=session_factory, + backend=backend, + config=RenewalConfig(max_retries=1), + scheduler=scheduler, + ) + + await renewal_scheduler.renew_certificate(certificate.id) + + async with session_factory() as session: + refreshed = await session.get(Certificate, certificate.id) + attempt = (await session.execute(select(RenewalAttempt))).scalar_one() + + assert refreshed is not None + assert refreshed.status == CertificateStatus.FAILED + assert attempt.error_category == "terminal" + assert scheduler.get_job(f"renew-retry:{certificate.id}") is None diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py new file mode 100644 index 0000000..b44f8a1 --- /dev/null +++ b/tests/test_webhooks.py @@ -0,0 +1,183 @@ +"""Tests for webhook signing and delivery.""" + +from __future__ import annotations + +import hmac +from collections.abc import AsyncGenerator +from pathlib import Path + +import httpx2 +import pytest +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from acme_api.config import AppSettings, DatabaseConfig, DeploymentConfig +from acme_api.db import get_session_factory, init_db, init_engine +from acme_api.models.certificate import Certificate, CertificateStatus +from acme_api.models.event import Event +from acme_api.models.webhook import WebhookConfig +from acme_api.webhooks import ( + SIGNATURE_HEADER, + WebhookDeliverySettings, + WebhookDispatcher, + WebhookPayload, + encode_payload, + sign_payload, +) + + +@pytest.fixture() +async def session_factory( + tmp_path: Path, +) -> AsyncGenerator[async_sessionmaker[AsyncSession], None]: + settings = AppSettings( + database=DatabaseConfig(url=f"sqlite+aiosqlite:///{tmp_path}/test.db"), + deployment=DeploymentConfig(directory=tmp_path), + ) + engine = init_engine(settings) + try: + await init_db(engine) + yield get_session_factory() + finally: + await engine.dispose() + + +@pytest.fixture() +async def db_session( + session_factory: async_sessionmaker[AsyncSession], +) -> AsyncGenerator[AsyncSession, None]: + async with session_factory() as session: + yield session + + +def test_sign_payload_is_hmac_sha256() -> None: + """Webhook signatures are stable HMAC-SHA256 values.""" + payload = WebhookPayload( + event="certificate.renewed", + certificate_name="example", + domains=["example.com"], + expiry=None, + ) + body = encode_payload(payload) + + signature = sign_payload("secret", body) + + assert signature.startswith("sha256=") + assert hmac.compare_digest(signature, sign_payload("secret", body)) + + +@pytest.mark.anyio +async def test_dispatch_sends_signed_payload(db_session: AsyncSession) -> None: + """A matching enabled webhook receives a signed JSON payload.""" + certificate = Certificate( + name="example-cert", + domains=["example.com"], + acme_account_ref="le", + dns_provider_ref="cf", + status=CertificateStatus.VALID, + ) + db_session.add(certificate) + db_session.add( + WebhookConfig( + url="https://hooks.example.test/certs", + events=["certificate.renewed"], + secret="shared-secret", + ) + ) + await db_session.commit() + captured: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + captured.append(request) + return httpx2.Response(204) + + async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as client: + async with WebhookDispatcher(db_session, client=client) as dispatcher: + delivered = await dispatcher.dispatch_certificate_event( + "certificate.renewed", certificate + ) + + assert delivered == 1 + assert len(captured) == 1 + assert captured[0].headers[SIGNATURE_HEADER] == sign_payload( + "shared-secret", captured[0].content + ) + + +@pytest.mark.anyio +async def test_dispatch_retries_then_succeeds(db_session: AsyncSession) -> None: + """Transient HTTP failures are retried before success.""" + certificate = Certificate( + name="retry-cert", + domains=["retry.example.com"], + acme_account_ref="le", + dns_provider_ref="cf", + status=CertificateStatus.VALID, + ) + db_session.add(certificate) + db_session.add( + WebhookConfig( + url="https://hooks.example.test/retry", + events=["*"], + secret="shared-secret", + ) + ) + await db_session.commit() + attempts = 0 + + def handler(_request: httpx2.Request) -> httpx2.Response: + nonlocal attempts + attempts += 1 + if attempts == 1: + return httpx2.Response(503) + return httpx2.Response(200) + + settings = WebhookDeliverySettings(max_retries=1, backoff_seconds=0) + async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as client: + async with WebhookDispatcher(db_session, settings, client) as dispatcher: + delivered = await dispatcher.dispatch_certificate_event( + "certificate.failed", certificate + ) + + assert delivered == 1 + assert attempts == 2 + + +@pytest.mark.anyio +async def test_dispatch_logs_failed_delivery(db_session: AsyncSession) -> None: + """Failed webhook delivery writes an audit event.""" + certificate = Certificate( + name="failed-hook-cert", + domains=["failed-hook.example.com"], + acme_account_ref="le", + dns_provider_ref="cf", + status=CertificateStatus.VALID, + ) + db_session.add(certificate) + db_session.add( + WebhookConfig( + url="https://hooks.example.test/fail", + events=["certificate.failed"], + secret="shared-secret", + ) + ) + await db_session.commit() + + def handler(_request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(500) + + settings = WebhookDeliverySettings(max_retries=0, backoff_seconds=0) + async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as client: + async with WebhookDispatcher(db_session, settings, client) as dispatcher: + delivered = await dispatcher.dispatch_certificate_event( + "certificate.failed", certificate + ) + + events = ( + await db_session.execute( + select(Event).where(Event.event_type == "webhook.delivery_failed") + ) + ).scalars().all() + assert delivered == 0 + assert len(events) == 1 + assert events[0].details["event"] == "certificate.failed" From 24701432c81255f338be6b66d7e1101997ae6284 Mon Sep 17 00:00:00 2001 From: Streaky Date: Thu, 2 Jul 2026 16:04:48 +0100 Subject: [PATCH 15/26] docs rejig --- README.md | 3 +- docs/future.md | 50 +++++++++++++++++++ docs/outline.md | 43 ++++++++--------- docs/plan.md | 112 ++++++++++++++++++++++++++++++++++++------- pyproject.toml | 1 - requirements-dev.txt | 12 +++-- requirements.txt | 2 - 7 files changed, 174 insertions(+), 49 deletions(-) create mode 100644 docs/future.md diff --git a/README.md b/README.md index d1d6c7c..d08b9da 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,6 @@ curl http://localhost:8080/v1/certificates | **Accounts** | Manage multiple CA accounts (Let's Encrypt prod/staging, ZeroSSL) | | **Renewals** | Automatic before expiry with tracked state machine | | **Webhooks** | Events on issue/renew/fail/expiry/revocation | -| **Metrics** | Prometheus-compatible counters for certs, renewals, webhooks | | **Logging** | Structured JSON to stdout and file | | **Auth (v1)** | API key authentication with RBAC roles: Admin, Operator, Read Only | @@ -138,7 +137,7 @@ Certificates are written atomically: temporary files → flush → rename. Consu ## Project status -Prototype. API surface and architecture are defined; implementation underway. See `docs/outline.md` for the full specification. +Prototype. API surface and architecture are defined; implementation underway. See `docs/outline.md` for the v1 specification and `docs/future.md` for deferred ideas. ## License diff --git a/docs/future.md b/docs/future.md new file mode 100644 index 0000000..2544164 --- /dev/null +++ b/docs/future.md @@ -0,0 +1,50 @@ +# Future Work + +This document captures useful ideas that are intentionally outside the immediate v1 implementation path. + +## Observability + +### Prometheus Metrics + +Prometheus-compatible metrics are useful, but should wait until the certificate lifecycle service boundary is stable. Metrics added too early risk counting helper-level activity instead of real user-visible lifecycle outcomes. + +Potential metrics: + +* `certificates_total` +* `certificates_expiring` +* `renewals_total` +* `renewals_failed_total` +* `webhook_deliveries_total` +* `webhook_failures_total` + +Potential endpoint: + +* `GET /metrics` + +Before implementing metrics, decide: + +* Whether Prometheus should be a hard runtime dependency or optional extra. +* Whether `/metrics` requires auth or is intended for a trusted network only. +* Which events are counted: requested, started, completed, failed, retried, or deployed. +* How to handle process restarts with SQLite-backed state and in-memory counters. + +## ACME and Validation + +* Additional ACME backends. +* HTTP-01 support. +* TLS-ALPN-01 support. +* ACME External Account Binding (EAB). + +## Deployment Targets + +* Remote deployment targets such as SSH, Kubernetes Secrets, S3-compatible object storage, or Vault. +* Multi-node deployments. + +## Product Surface + +* Web administration interface. +* Fine-grained permissions. +* Event streaming. +* CLI generated from the OpenAPI specification. +* Certificate inventory search. +* CA-level certificate revocation. diff --git a/docs/outline.md b/docs/outline.md index 7026838..49e2eba 100644 --- a/docs/outline.md +++ b/docs/outline.md @@ -95,11 +95,14 @@ Example: ```json { + "name": "wildcard-example", "domains": [ "*.example.com", "example.com" ], - "dns_provider": "production" + "dns_provider_ref": "production", + "acme_account_ref": "letsencrypt-production", + "key_algorithm": "ecdsa" } ``` @@ -206,9 +209,9 @@ A certificate contains: Supported key algorithms: -* RSA -* ECDSA P-256 -* ECDSA P-384 +* `ecdsa` +* `rsa-2048` +* `rsa-4096` --- @@ -287,14 +290,18 @@ Webhook payload example: ```json { "event": "certificate.renewed", - "certificate": "mail.example.com", - "expires": "2027-05-20T14:00:00Z", + "certificate_id": "0d15428a-9b52-4f1e-9965-31e57615c081", + "certificate_name": "mail.example.com", + "expiry": "2027-05-20T14:00:00+00:00", "domains": [ "mail.example.com" - ] + ], + "details": {} } ``` +Webhook requests are signed with `X-Webhook-Signature: sha256=`. + --- # Security @@ -326,12 +333,14 @@ Version 1 should use SQLite. Persistent data includes: * certificates -* accounts -* providers +* bootstrap/API-managed API keys * renewal schedule * webhook configuration * audit log +ACME accounts and DNS provider aliases are administrator-owned configuration +in v1 and are exposed through read-only API endpoints. + Certificate material remains on the filesystem. --- @@ -380,21 +389,6 @@ Log levels: --- -# Metrics - -Expose Prometheus metrics. - -Example metrics: - -* certificates_total -* certificates_expiring -* renewals_total -* renewals_failed -* webhook_deliveries_total -* webhook_failures_total - ---- - # Design Principles * API-first @@ -412,6 +406,7 @@ Example metrics: Potential future features include: +* Prometheus-compatible metrics * Additional ACME backends * HTTP-01 support * TLS-ALPN-01 support diff --git a/docs/plan.md b/docs/plan.md index 6fe7983..fceac66 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -2,6 +2,8 @@ > Derived from `docs/outline.md`. Targets Python 3.14, strict mypy, 80% per-file coverage gate. > Phase 0/1 foundation exists: package skeleton, config loading, structured logging, request IDs, health endpoint, and tests are wired. +> Phases 2-8 now provide the core DB models, backend abstraction, auth, API routes, atomic deployer, renewal scheduler, and webhook dispatcher. +> Implementation lesson: phases 5-8 built the pieces, but a dedicated lifecycle orchestration pass is needed before readiness/final polish so create/manual-renew/revoke consistently call the backend, deploy artifacts, update state, schedule jobs, and emit all lifecycle webhooks. --- @@ -15,8 +17,7 @@ - APScheduler (renewal jobs) - Pydantic Settings (`pydantic-settings`) — config loading beyond flat YAML - HTTPX2 (webhook delivery; acme.sh subprocess parsing is local but webhooks are HTTP) - - Prometheus Client (`prometheus-client`) - - Passlib + bcrypt (API key hashing) + - Passlib (PBKDF2-SHA512 API key hashing) - `aiofiles` (async filesystem writes for atomic deployment) - Update `[project.scripts]` entry point pointing to the main CLI. - Regenerate `requirements.txt` / `requirements-dev.txt` via `make deps-update`. @@ -122,10 +123,12 @@ Accounts and providers are exposed through read-only API endpoints, but are not - Admin: all endpoints. - Operator: create/renew certificates, view status, view events. - Read Only: GET endpoints only. - - API key hashing via Passlib + bcrypt for storage. + - API key hashing via Passlib PBKDF2-SHA512 for storage. **Acceptance:** Unauthenticated requests return 401; insufficient role returns 403; role dependencies are reusable by later route modules; auth behavior is covered by an RBAC test matrix. +**Implementation note:** API keys are hashed with Passlib PBKDF2-SHA512. This avoids bcrypt's 72-byte input truncation behavior for high-entropy API keys while still using Passlib. + --- ## Phase 5 — Core API Endpoints (Certificates, Accounts, Providers, Events) @@ -149,14 +152,17 @@ Accounts and providers are exposed through read-only API endpoints, but are not - `GET /v1/events` — query audit/event log with filtering by type, certificate, time range. ### Implementation Details -- FastAPI router structure: `acme_api/routes/certificates.py`, `accounts.py`, `providers.py`, `events.py`. +- FastAPI router structure: `acme_api/routers/certificates.py`, `config.py`, `events.py`. - Dependency injection for DB sessions and backend instances. - Auth/RBAC dependencies applied at route level. +- Initial implementation may create DB records and expose routes before full asynchronous issuance orchestration is wired. - Issuance state transitions: Pending → Issuing → Valid or Failed. - OpenAPI metadata on all endpoints (tags, summary, responses). **Acceptance:** All endpoints respond with correct status codes; input validation via Pydantic schemas; database CRUD wired end-to-end; OpenAPI docs at `/docs` reflect the API. +**Implementation note:** The current Phase 5 route layer is DB-backed and RBAC-protected, but certificate creation and manual renewal do not yet run the ACME backend/deployer. That is now tracked explicitly in Phase 8.5. + --- ## Phase 6 — Certificate Filesystem Deployment @@ -182,6 +188,8 @@ Accounts and providers are exposed through read-only API endpoints, but are not **Acceptance:** Deployment produces correct file layout; atomic rename guarantees consumers never see partial writes; filesystem permissions are set correctly (`0644` for certs, `0600` for keys). +**Implementation note:** The deployer is implemented as a reusable boundary. It is not yet called from certificate issuance/renewal flows; that wiring belongs in Phase 8.5. + --- ## Phase 7 — Renewal Scheduler @@ -198,6 +206,8 @@ Accounts and providers are exposed through read-only API endpoints, but are not **Acceptance:** Certificates within renewal window are picked up on startup; scheduled jobs execute and trigger backend renewal; failures logged and reflected in certificate status. +**Implementation note:** The scheduler can renew valid certificates through an injected backend and emit renewed/failed webhooks. Deployment after renewal and full lifecycle integration are tracked in Phase 8.5. + --- ## Phase 8 — Webhook Notifications @@ -213,22 +223,60 @@ Accounts and providers are exposed through read-only API endpoints, but are not **Acceptance:** Webhooks fire on all lifecycle events; payload matches spec; HMAC signature verifiable by consumer; failed deliveries retried and logged. +**Implementation note:** The webhook dispatcher, HMAC signing, retry handling, DB-backed subscriptions, and failed-delivery audit events are implemented. Not every lifecycle event is emitted by route/service flows yet; that wiring belongs in Phase 8.5. + --- -## Phase 9 — Metrics & Health/Readiness Checks +## Phase 8.5 — Lifecycle Orchestration Gap Closure + +**Goal:** Stitch the implemented pieces from Phases 3, 5, 6, 7, and 8 into complete certificate workflows before adding readiness checks and production packaging. -**Goal:** Prometheus metrics endpoint and Kubernetes-ready health probes per outline spec. +### Certificate Creation +- `POST /v1/certificates` should: + - Create the DB record as `Pending`. + - Start issuance work without blocking on DNS propagation. + - Transition `Pending -> Issuing -> Valid` or `Failed`. + - Call the configured ACME backend with DNS-01 provider/account settings. + - Deploy successful issuance artifacts through `acme_api/deployer.py`. + - Set `expiry_date` from backend results. + - Emit audit events and webhooks for `certificate.created`, `certificate.issued`, and `certificate.failed`. + +### Manual Renewal +- `POST /v1/certificates/{id}/renew` should: + - Trigger the same renewal path used by the scheduler. + - Deploy renewed artifacts on success. + - Emit `certificate.renewed` or `certificate.failed`. + - Return `202 Accepted` after queuing/starting work, not after DNS propagation. + +### Revocation / Soft Delete +- `DELETE /v1/certificates/{id}` should: + - Mark the row `Revoked`. + - Remove pending renewal jobs for the certificate. + - Emit `certificate.revoked`. + - Defer actual ACME CA revocation unless explicitly added as a separate backend capability. + +### Expiry Notifications +- Scheduler should emit `certificate.expiring` for certificates inside the configured notification window without duplicating events every startup. + +### Service Boundary +- Add an application service layer instead of putting workflow logic inside FastAPI route functions or APScheduler callbacks. +- Keep backend, deployer, webhook dispatcher, and scheduler injectable for tests. + +**Acceptance:** Create, scheduled renew, manual renew, revoke, deploy, audit events, and webhooks work end-to-end with the mock backend; real acme.sh integration remains covered by subprocess-level tests and optional staging/Pebble tests. + +--- + +## Phase 9 — Readiness Checks + +**Goal:** Kubernetes-ready health probes that reflect actual runtime dependencies. -- `acme_api/metrics.py`: - - Prometheus Client SDK integration. - - Counters: `certificates_total`, `renewals_total`, `renewals_failed_total`, `webhook_deliveries_total`, `webhook_failures_total`. - - Gauge: `certificates_expiring` (count of certs expiring within N days). - - Metrics endpoint at `/metrics`. - Health/Readiness endpoints per outline: - `GET /health` — always returns 200 with uptime. - `GET /ready` — checks DB connectivity and acme.sh binary availability; returns 503 if any dependency is down. -**Acceptance:** `/metrics` exposes all defined metrics in Prometheus format; `/health` responds 200 on startup; `/ready` reflects actual dependency state. +**Acceptance:** `/health` responds 200 on startup; `/ready` reflects actual dependency state. + +**Deferred:** Prometheus-compatible metrics are useful but not part of the immediate v1 path. See `docs/future.md`. --- @@ -282,6 +330,31 @@ Accounts and providers are exposed through read-only API endpoints, but are not --- +## Phase 12.5 — Continuous Integration + +**Goal:** Add GitHub Actions CI that matches local quality gates and can be exercised locally before pushing. + +- Add `.github/workflows/ci.yml`. +- CI should run on pull requests and pushes to the default branch. +- CI should install Python 3.14 and project dependencies from pinned requirements. +- CI should run the same gates developers run locally: + - `make typecheck` + - `make lint` + - `make isort` + - `make check-max-lines` + - `make test` +- Prefer calling `make combined-check` from CI unless splitting jobs provides clearer failure reporting. +- Keep optional staging/Pebble ACME tests gated behind secrets or explicit workflow inputs. +- Keep Docker build smoke tests separate from the fast unit/integration gate if runtime becomes too slow. +- Use the existing local CI simulator before considering the workflow done: + - `make simulate-ci` + +**Local prerequisites:** `simulate-ci` uses `act`; configure `ACT_VERSION`, `ACT_PLATFORM`, and `ACT_IMAGE` in `.env` or the shell environment. + +**Acceptance:** GitHub Actions workflow is committed, `make simulate-ci` passes locally, and the remote workflow passes on the branch/PR. + +--- + ## Dependency Graph & Parallelism Phases with no hard dependencies can be worked in parallel: @@ -296,10 +369,11 @@ Phase 2 + Phase 3 + Phase 4 -> Phase 5 Phase 5 -> Phase 6 -> Phase 7 -> Phase 8 - -> Phase 9 -> Phase 10 -Phase 6 + Phase 7 + Phase 8 + Phase 9 + Phase 10 -> Phase 11 -> Phase 12 +Phase 6 + Phase 7 + Phase 8 -> Phase 8.5 +Phase 8.5 -> Phase 9 +Phase 8.5 + Phase 9 + Phase 10 -> Phase 11 -> Phase 12 -> Phase 12.5 ``` **Strict ordering:** @@ -307,9 +381,12 @@ Phase 6 + Phase 7 + Phase 8 + Phase 9 + Phase 10 -> Phase 11 -> Phase 12 - Phase 2 → Phase 4 (auth needs config and, if DB-backed keys are enabled, DB foundation). - Phase 2 + Phase 3 + Phase 4 → Phase 5 (API needs DB models, backend abstraction, and final auth dependencies). - Phase 5 → Phase 6, 7, 8 (deployment, scheduling, webhooks all act on certificates created by the API). -- Phase 6–10 can proceed in parallel once Phase 5 is complete. +- Phase 6–8 can proceed in parallel once Phase 5 is complete. +- Phase 8.5 depends on Phases 3, 5, 6, 7, and 8 because it wires backend, API routes, deployer, scheduler, and webhooks into full lifecycle workflows. +- Phase 9 should follow Phase 8.5 so readiness checks validate the final lifecycle dependencies. - Phase 10 depends on a working application (Phase 5+). - Phase 11 and 12 are final polish — depend on everything else. +- Phase 12.5 depends on the final local gates and integration suite so CI reflects the project’s real release criteria. --- @@ -326,10 +403,12 @@ Phase 6 + Phase 7 + Phase 8 + Phase 9 + Phase 10 -> Phase 11 -> Phase 12 | 6 | Unit: atomic write, permissions, metadata JSON | 90% | | 7 | Unit (mock backend): scheduling logic, state transitions | 90% | | 8 | Unit (mock HTTPX2): payload construction, HMAC, retry | 85% | -| 9 | Unit: metric counters, health checks | 85% | +| 8.5 | Integration: mock backend full lifecycle through API/service | 90% | +| 9 | Unit/integration: health and readiness checks | 85% | | 10 | Smoke test: container build + startup | — | | 11 | Regression: full `make combined-check` | 80% | | 12 | E2E: full lifecycle against mock/Pebble; optional staging ACME | 70% | +| 12.5 | CI workflow: GitHub Actions plus local `make simulate-ci` | — | --- @@ -341,3 +420,4 @@ Phase 6 + Phase 7 + Phase 8 + Phase 9 + Phase 10 -> Phase 11 -> Phase 12 | Atomic deploy on non-POSIX FS | Partial cert visible to consumers | Test with `os.rename` semantics; fallback to copy+rename if needed | | SQLite concurrency under load | Write conflicts | WAL mode enabled; connection pooling configured conservatively | | API key rotation without downtime | Auth outage during transition | Support multiple active keys; soft-delete old keys | +| Phase helpers remain disconnected | API appears complete but does not issue/deploy/notify end-to-end | Phase 8.5 service-layer integration before readiness/final polish | diff --git a/pyproject.toml b/pyproject.toml index 3d3f562..420ee2a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,6 @@ dependencies = [ "apscheduler", "pydantic-settings", "httpx2", - "prometheus-client", "passlib[bcrypt]", "aiofiles", ] diff --git a/requirements-dev.txt b/requirements-dev.txt index 3a568ad..a7084d0 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -12,6 +12,8 @@ aiosqlite==0.22.1 # via # -c requirements.txt # acme.api (pyproject.toml) +alembic==1.18.5 + # via acme.api (pyproject.toml) annotated-doc==0.0.4 # via # -c requirements.txt @@ -89,6 +91,10 @@ isort==8.0.1 # pylint librt==0.12.0 # via mypy +mako==1.3.12 + # via alembic +markupsafe==3.0.3 + # via mako mccabe==0.7.0 # via # flake8 @@ -119,10 +125,6 @@ pluggy==1.6.0 # via # pytest # pytest-cov -prometheus-client==0.25.0 - # via - # -c requirements.txt - # acme.api (pyproject.toml) pycodestyle==2.14.0 # via flake8 pydantic==2.13.4 @@ -166,6 +168,7 @@ sqlalchemy[asyncio]==2.0.51 # via # -c requirements.txt # acme.api (pyproject.toml) + # alembic starlette==1.3.1 # via # -c requirements.txt @@ -182,6 +185,7 @@ types-pyyaml==6.0.12.20260518 typing-extensions==4.15.0 # via # -c requirements.txt + # alembic # fastapi # mypy # pydantic diff --git a/requirements.txt b/requirements.txt index 52a5ea7..37d1b02 100644 --- a/requirements.txt +++ b/requirements.txt @@ -43,8 +43,6 @@ idna==3.18 # httpx2 passlib==1.7.4 # via acme.api (pyproject.toml) -prometheus-client==0.25.0 - # via acme.api (pyproject.toml) pydantic==2.13.4 # via # fastapi From cf79e0862f463646b8799679acc708cbad30432f Mon Sep 17 00:00:00 2001 From: Streaky Date: Thu, 2 Jul 2026 17:03:03 +0100 Subject: [PATCH 16/26] phases 8.5 and 9 --- acme_api/main.py | 71 ++++++-- acme_api/readiness.py | 48 ++++++ acme_api/routers/certificates.py | 113 ++++++------ acme_api/scheduler.py | 94 +++++++++- acme_api/services/__init__.py | 1 + acme_api/services/certificates.py | 278 ++++++++++++++++++++++++++++++ docs/plan.md | 8 +- tests/test_api_phase5.py | 73 +++++++- tests/test_health.py | 3 +- tests/test_main.py | 32 +++- tests/test_scheduler.py | 29 ++++ 11 files changed, 673 insertions(+), 77 deletions(-) create mode 100644 acme_api/readiness.py create mode 100644 acme_api/services/__init__.py create mode 100644 acme_api/services/certificates.py diff --git a/acme_api/main.py b/acme_api/main.py index 4ef8b8b..c41ad52 100644 --- a/acme_api/main.py +++ b/acme_api/main.py @@ -7,6 +7,7 @@ from __future__ import annotations import logging +import time from contextlib import asynccontextmanager from pathlib import Path from typing import AsyncGenerator @@ -14,6 +15,7 @@ import uvicorn from fastapi import FastAPI, Request, Response from fastapi.responses import JSONResponse +from sqlalchemy.ext.asyncio import AsyncSession from acme_api.auth.bootstrap import seed_initial_keys from acme_api.backend.acmesh_backend import AcmeShBackend, _AcmeShBackendConfig @@ -21,8 +23,10 @@ from acme_api.db import get_db, get_session_factory, init_db, init_engine from acme_api.logging import setup_logging from acme_api.middleware import RequestIdMiddleware +from acme_api.readiness import readiness_status from acme_api.routers import certificates_router, config_router, events_router -from acme_api.scheduler import RenewalScheduler +from acme_api.scheduler import RenewalDeploymentConfig, RenewalScheduler +from acme_api.services.certificates import CertificateLifecycleService from acme_api.webhooks import WebhookDeliverySettings, WebhookDispatcher @@ -56,27 +60,45 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]: "seeded api key | name=%s role=%s", key.name, key.role.value ) - renewal_scheduler = RenewalScheduler( - session_factory=get_session_factory(), - backend=AcmeShBackend( - _AcmeShBackendConfig( - binary_path=Path(settings.acme.binary_path), - home_dir=settings.acme.home_dir, - log_file=None, - force_renewal=False, - dnssleep_seconds=None, - ) - ), - config=settings.renewal, - webhook_dispatcher_factory=lambda session: WebhookDispatcher( + backend = getattr(app.state, "acme_backend", None) or AcmeShBackend( + _AcmeShBackendConfig( + binary_path=Path(settings.acme.binary_path), + home_dir=settings.acme.home_dir, + log_file=None, + force_renewal=False, + dnssleep_seconds=None, + ) + ) + app.state.acme_backend = backend + + def webhook_dispatcher_factory(session: AsyncSession) -> WebhookDispatcher: + return WebhookDispatcher( session, WebhookDeliverySettings( timeout_seconds=settings.webhooks.timeout_seconds, max_retries=settings.webhooks.max_retries, backoff_seconds=settings.webhooks.backoff_seconds, ), + ) + renewal_scheduler = RenewalScheduler( + session_factory=get_session_factory(), + backend=backend, + config=settings.renewal, + webhook_dispatcher_factory=webhook_dispatcher_factory, + deployment=RenewalDeploymentConfig( + root=settings.deployment.directory, + permissions_cert=settings.deployment.permissions_cert, + permissions_key=settings.deployment.permissions_key, ), ) + app.state.renewal_scheduler = renewal_scheduler + app.state.certificate_service = CertificateLifecycleService( + session_factory=get_session_factory(), + backend=backend, + settings=settings, + scheduler=renewal_scheduler, + webhook_dispatcher_factory=webhook_dispatcher_factory, + ) await renewal_scheduler.start() try: @@ -111,6 +133,7 @@ def create_app(settings: AppSettings | None = None) -> FastAPI: ) app.state.settings = settings + app.state.started_at = time.monotonic() # Middleware: request ID injection (outermost) app.add_middleware(RequestIdMiddleware) @@ -119,9 +142,25 @@ def create_app(settings: AppSettings | None = None) -> FastAPI: app.include_router(events_router) @app.get("/health", tags=["Health"]) - async def health() -> dict[str, str]: + async def health() -> dict[str, str | float]: """Liveness probe — always returns 200 when the process is running.""" - return {"status": "ok"} + return { + "status": "ok", + "uptime_seconds": round(time.monotonic() - app.state.started_at, 3), + } + + @app.get("/ready", tags=["Health"]) + async def ready() -> JSONResponse: + """Readiness probe for database and acme.sh executable availability.""" + ready_ok, checks = await readiness_status( + settings=app.state.settings, + session_factory=get_session_factory(), + ) + status_code = 200 if ready_ok else 503 + return JSONResponse( + status_code=status_code, + content={"status": "ready" if ready_ok else "not_ready", "checks": checks}, + ) @app.exception_handler(Exception) async def unhandled_exception_handler( diff --git a/acme_api/readiness.py b/acme_api/readiness.py new file mode 100644 index 0000000..89f04be --- /dev/null +++ b/acme_api/readiness.py @@ -0,0 +1,48 @@ +"""Readiness probe checks for runtime dependencies.""" + +from __future__ import annotations + +import os +import shutil +from pathlib import Path +from typing import Any + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from acme_api.config import AppSettings + + +async def readiness_status( + *, + settings: AppSettings, + session_factory: async_sessionmaker[AsyncSession], +) -> tuple[bool, dict[str, Any]]: + """Return aggregate readiness and dependency check details.""" + checks = { + "database": await _database_ready(session_factory), + "acme_binary": _acme_binary_ready(settings.acme.binary_path), + } + return all(check["ok"] for check in checks.values()), checks + + +async def _database_ready( + session_factory: async_sessionmaker[AsyncSession], +) -> dict[str, Any]: + try: + async with session_factory() as session: + await session.execute(text("SELECT 1")) + except Exception as exc: # pylint: disable=broad-exception-caught + return {"ok": False, "error": str(exc)} + return {"ok": True} + + +def _acme_binary_ready(binary_path: str) -> dict[str, Any]: + resolved = shutil.which(binary_path) + if resolved is None: + path = Path(binary_path) + if path.is_file() and os.access(path, os.X_OK): + resolved = str(path) + if resolved is None: + return {"ok": False, "error": f"acme.sh binary not executable: {binary_path}"} + return {"ok": True, "path": resolved} diff --git a/acme_api/routers/certificates.py b/acme_api/routers/certificates.py index 7eb8d48..e382a78 100644 --- a/acme_api/routers/certificates.py +++ b/acme_api/routers/certificates.py @@ -4,16 +4,29 @@ import uuid -from fastapi import APIRouter, Depends, HTTPException, Query, Response, status +from fastapi import ( + APIRouter, + BackgroundTasks, + Depends, + HTTPException, + Query, + Request, + Response, + status, +) from sqlalchemy import select -from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from acme_api.auth.rbac import require_operator, require_readonly from acme_api.db import get_db_session from acme_api.models.certificate import Certificate, CertificateStatus -from acme_api.models.event import Event from acme_api.schemas.certificate import CertificateCreate, CertificateRead +from acme_api.services.certificates import ( + CertificateConflictError, + CertificateLifecycleService, + CertificateNotFoundError, + CertificateNotRenewableError, +) router = APIRouter(prefix="/v1/certificates", tags=["Certificates"]) @@ -26,37 +39,21 @@ ) async def create_certificate( payload: CertificateCreate, + background_tasks: BackgroundTasks, + request: Request, _: object = Depends(require_operator), - db_session: AsyncSession = Depends(get_db_session), ) -> Certificate: - """Create a pending certificate record and audit event.""" - certificate = Certificate( - name=payload.name, - domains=payload.domains, - acme_account_ref=payload.acme_account_ref, - dns_provider_ref=payload.dns_provider_ref, - key_algorithm=payload.key_algorithm, - status=CertificateStatus.PENDING, - ) - db_session.add(certificate) + """Create a pending certificate record and queue issuance.""" + service = _certificate_service(request) try: - await db_session.flush() - except IntegrityError as exc: - await db_session.rollback() + certificate = await service.create_certificate(payload) + except CertificateConflictError as exc: raise HTTPException( status_code=status.HTTP_409_CONFLICT, - detail="Certificate name already exists.", + detail=str(exc), ) from exc - db_session.add( - Event( - event_type="certificate.created", - certificate_id=certificate.id, - details={"name": certificate.name, "domains": certificate.domains}, - ) - ) - await db_session.commit() - await db_session.refresh(certificate) + background_tasks.add_task(service.issue_certificate, certificate.id) return certificate @@ -110,25 +107,17 @@ async def get_certificate( ) async def revoke_certificate( certificate_id: uuid.UUID, + request: Request, _: object = Depends(require_operator), - db_session: AsyncSession = Depends(get_db_session), ) -> Response: """Soft-delete a certificate by marking it revoked.""" - certificate = await db_session.get(Certificate, certificate_id) - if certificate is None: + try: + await _certificate_service(request).revoke_certificate(certificate_id) + except CertificateNotFoundError as exc: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Certificate not found.", - ) - certificate.status = CertificateStatus.REVOKED - db_session.add( - Event( - event_type="certificate.revoked", - certificate_id=certificate.id, - details={"name": certificate.name}, - ) - ) - await db_session.commit() + ) from exc return Response(status_code=status.HTTP_204_NO_CONTENT) @@ -140,24 +129,40 @@ async def revoke_certificate( ) async def renew_certificate( certificate_id: uuid.UUID, + background_tasks: BackgroundTasks, + request: Request, _: object = Depends(require_operator), - db_session: AsyncSession = Depends(get_db_session), ) -> Certificate: - """Mark a certificate as renewing and record the manual trigger.""" - certificate = await db_session.get(Certificate, certificate_id) - if certificate is None: + """Queue a manual renewal through the scheduler renewal path.""" + service = _certificate_service(request) + try: + certificate = await service.request_manual_renewal(certificate_id) + except CertificateNotFoundError as exc: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Certificate not found.", + ) from exc + except CertificateNotRenewableError as exc: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=str(exc), + ) from exc + + scheduler = getattr(request.app.state, "renewal_scheduler", None) + if scheduler is None: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Renewal scheduler is not available.", ) - certificate.status = CertificateStatus.RENEWING - db_session.add( - Event( - event_type="certificate.renewal_requested", - certificate_id=certificate.id, - details={"name": certificate.name}, - ) - ) - await db_session.commit() - await db_session.refresh(certificate) + background_tasks.add_task(scheduler.renew_certificate, certificate.id) return certificate + + +def _certificate_service(request: Request) -> CertificateLifecycleService: + service = getattr(request.app.state, "certificate_service", None) + if not isinstance(service, CertificateLifecycleService): + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Certificate lifecycle service is not available.", + ) + return service diff --git a/acme_api/scheduler.py b/acme_api/scheduler.py index 310db10..e08cca9 100644 --- a/acme_api/scheduler.py +++ b/acme_api/scheduler.py @@ -2,9 +2,11 @@ from __future__ import annotations +import dataclasses as dc import datetime as dt import uuid from collections.abc import Callable +from pathlib import Path from typing import Awaitable from apscheduler.schedulers.asyncio import ( # type: ignore[import-untyped] @@ -15,31 +17,45 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from acme_api.backend.acmesh_backend import AcmeShError, TransientAcmeShError +from acme_api.backend.dataclasses import IssuanceResult from acme_api.backend.protocol import AcmeBackend from acme_api.config import RenewalConfig +from acme_api.deployer import DeploymentError, deploy_issuance_result from acme_api.models.certificate import Certificate, CertificateStatus from acme_api.models.event import Event from acme_api.models.renewal_attempt import RenewalAttempt +from acme_api.services.certificates import expiring_event_exists from acme_api.webhooks import WebhookDispatcher WebhookDispatcherFactory = Callable[[AsyncSession], WebhookDispatcher] +@dc.dataclass(frozen=True) +class RenewalDeploymentConfig: + """Filesystem deployment settings for successful renewals.""" + + root: Path + permissions_cert: int = 0o644 + permissions_key: int = 0o600 + + class RenewalScheduler: """Schedules and executes automatic certificate renewals.""" - def __init__( + def __init__( # pylint: disable=too-many-arguments,too-many-positional-arguments self, session_factory: async_sessionmaker[AsyncSession], backend: AcmeBackend, config: RenewalConfig, webhook_dispatcher_factory: WebhookDispatcherFactory | None = None, + deployment: RenewalDeploymentConfig | None = None, scheduler: AsyncIOScheduler | None = None, ) -> None: self._session_factory = session_factory self._backend = backend self._config = config self._webhook_dispatcher_factory = webhook_dispatcher_factory + self._deployment = deployment self._scheduler = scheduler or AsyncIOScheduler(timezone=dt.timezone.utc) async def start(self) -> None: @@ -62,6 +78,8 @@ async def rebuild_jobs(self) -> int: select(Certificate).where(Certificate.status == CertificateStatus.VALID) ) certificates = list(result.scalars().all()) + for certificate in certificates: + await self._emit_expiring_if_due(session, certificate) for certificate in certificates: self.schedule_certificate(certificate) @@ -83,6 +101,13 @@ def schedule_certificate(self, certificate: Certificate) -> dt.datetime | None: ) return run_at + def remove_certificate(self, certificate_id: uuid.UUID) -> None: + """Remove queued renewal and retry jobs for a certificate.""" + for job_id in (_job_id(certificate_id), _retry_job_id(certificate_id)): + job = self._scheduler.get_job(job_id) + if job is not None: + job.remove() + async def renew_certificate(self, certificate_id: uuid.UUID) -> None: """Run one renewal attempt for the latest certificate row state.""" async with self._session_factory() as session: @@ -114,6 +139,25 @@ async def renew_certificate(self, certificate_id: uuid.UUID) -> None: transient=False, ) return + except DeploymentError as exc: + await self._record_failure( + session=session, + certificate=certificate, + error=exc, + transient=False, + ) + return + + try: + deployment_path = self._deploy_result(result, certificate.acme_account_ref) + except DeploymentError as exc: + await self._record_failure( + session=session, + certificate=certificate, + error=exc, + transient=False, + ) + return certificate.expiry_date = result.cert.expires_at certificate.status = CertificateStatus.VALID @@ -130,6 +174,9 @@ async def renew_certificate(self, certificate_id: uuid.UUID) -> None: details={ "domains": certificate.domains, "expires_at": result.cert.expires_at.isoformat(), + "deployment_path": str(deployment_path) + if deployment_path is not None + else None, }, ) ) @@ -138,6 +185,51 @@ async def renew_certificate(self, certificate_id: uuid.UUID) -> None: self.schedule_certificate(certificate) + async def _emit_expiring_if_due( + self, + session: AsyncSession, + certificate: Certificate, + ) -> None: + """Emit one expiring event for certificates inside the renewal window.""" + if certificate.expiry_date is None: + return + expiry = _as_utc(certificate.expiry_date) + now = dt.datetime.now(dt.timezone.utc) + if expiry - dt.timedelta(days=self._config.window_days) > now: + return + if await expiring_event_exists(session, certificate.id): + return + + session.add( + Event( + event_type="certificate.expiring", + certificate_id=certificate.id, + details={ + "domains": certificate.domains, + "expires_at": expiry.isoformat(), + }, + ) + ) + await session.commit() + await self._dispatch_webhook(session, "certificate.expiring", certificate) + + def _deploy_result( + self, + result: IssuanceResult, + issuer: str | None, + ) -> Path | None: + """Deploy renewed artifacts when deployment is enabled for the scheduler.""" + if self._deployment is None: + return None + deployed = deploy_issuance_result( + result, + self._deployment.root, + permissions_cert=self._deployment.permissions_cert, + permissions_key=self._deployment.permissions_key, + issuer=issuer, + ) + return deployed.directory + async def _record_failure( self, *, diff --git a/acme_api/services/__init__.py b/acme_api/services/__init__.py new file mode 100644 index 0000000..9415f6b --- /dev/null +++ b/acme_api/services/__init__.py @@ -0,0 +1 @@ +"""Application service layer.""" diff --git a/acme_api/services/certificates.py b/acme_api/services/certificates.py new file mode 100644 index 0000000..1b67341 --- /dev/null +++ b/acme_api/services/certificates.py @@ -0,0 +1,278 @@ +"""Certificate lifecycle orchestration service.""" + +from __future__ import annotations + +import uuid +from collections.abc import Callable +from pathlib import Path +from typing import Protocol + +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from acme_api.backend.acmesh_backend import AcmeShError, TransientAcmeShError +from acme_api.backend.protocol import AcmeBackend +from acme_api.config import AcmeAccountConfig, AppSettings, DnsProviderConfig +from acme_api.deployer import DeploymentError, deploy_issuance_result +from acme_api.models.certificate import Certificate, CertificateStatus +from acme_api.models.event import Event +from acme_api.schemas.certificate import CertificateCreate +from acme_api.webhooks import WebhookDispatcher + + +class CertificateLifecycleError(Exception): + """Base exception for lifecycle service errors.""" + + +class CertificateConflictError(CertificateLifecycleError): + """Raised when a certificate cannot be created due to a uniqueness conflict.""" + + +class CertificateNotFoundError(CertificateLifecycleError): + """Raised when a certificate row does not exist.""" + + +class CertificateNotRenewableError(CertificateLifecycleError): + """Raised when a certificate is not eligible for renewal.""" + + +class RenewalSchedulerProtocol(Protocol): + """Scheduler operations used by the certificate lifecycle service.""" + + def schedule_certificate(self, certificate: Certificate) -> object | None: + """Schedule future renewal for a certificate.""" + + def remove_certificate(self, certificate_id: uuid.UUID) -> None: + """Remove queued renewal jobs for a certificate.""" + + +WebhookDispatcherFactory = Callable[[AsyncSession], WebhookDispatcher] + + +class CertificateLifecycleService: + """Coordinates certificate DB state, ACME backend, deployment, and webhooks.""" + + def __init__( + self, + *, + session_factory: async_sessionmaker[AsyncSession], + backend: AcmeBackend, + settings: AppSettings, + scheduler: RenewalSchedulerProtocol | None = None, + webhook_dispatcher_factory: WebhookDispatcherFactory | None = None, + ) -> None: + self._session_factory = session_factory + self._backend = backend + self._settings = settings + self._scheduler = scheduler + self._webhook_dispatcher_factory = webhook_dispatcher_factory + + async def create_certificate(self, payload: CertificateCreate) -> Certificate: + """Create a pending certificate row and emit the creation event.""" + async with self._session_factory() as session: + certificate = Certificate( + name=payload.name, + domains=payload.domains, + acme_account_ref=payload.acme_account_ref, + dns_provider_ref=payload.dns_provider_ref, + key_algorithm=payload.key_algorithm, + status=CertificateStatus.PENDING, + ) + session.add(certificate) + try: + await session.flush() + except IntegrityError as exc: + await session.rollback() + raise CertificateConflictError( + "Certificate name already exists." + ) from exc + + await self._record_event( + session, + certificate, + "certificate.created", + {"name": certificate.name, "domains": certificate.domains}, + ) + await session.commit() + await session.refresh(certificate) + await self._dispatch_webhook(session, "certificate.created", certificate) + return certificate + + async def issue_certificate(self, certificate_id: uuid.UUID) -> None: + """Issue a pending certificate and deploy successful artifacts.""" + async with self._session_factory() as session: + certificate = await session.get(Certificate, certificate_id) + if certificate is None: + return + if certificate.status != CertificateStatus.PENDING: + return + + certificate.status = CertificateStatus.ISSUING + await session.commit() + + try: + provider = self._dns_provider(certificate.dns_provider_ref) + account = self._acme_account(certificate.acme_account_ref) + result = await self._backend.issue_certificate( + domains=certificate.domains, + method="dns-01", + challenge_params={ + "dns_provider": provider.provider_name, + "env_vars_file": str(provider.env_vars_file_path), + }, + account_key_path=_account_key_path(account), + ) + deployed = deploy_issuance_result( + result, + self._settings.deployment.directory, + permissions_cert=self._settings.deployment.permissions_cert, + permissions_key=self._settings.deployment.permissions_key, + issuer=certificate.acme_account_ref, + ) + except (AcmeShError, DeploymentError) as exc: + await self._mark_failed(session, certificate, exc) + return + + certificate.expiry_date = result.cert.expires_at + certificate.status = CertificateStatus.VALID + await self._record_event( + session, + certificate, + "certificate.issued", + { + "domains": certificate.domains, + "expires_at": result.cert.expires_at.isoformat(), + "deployment_path": str(deployed.directory), + }, + ) + await session.commit() + await session.refresh(certificate) + await self._dispatch_webhook(session, "certificate.issued", certificate) + if self._scheduler is not None: + self._scheduler.schedule_certificate(certificate) + + async def revoke_certificate(self, certificate_id: uuid.UUID) -> None: + """Soft-delete a certificate by marking it revoked and unscheduling renewal.""" + async with self._session_factory() as session: + certificate = await session.get(Certificate, certificate_id) + if certificate is None: + raise CertificateNotFoundError("Certificate not found.") + + certificate.status = CertificateStatus.REVOKED + await self._record_event( + session, + certificate, + "certificate.revoked", + {"name": certificate.name}, + ) + await session.commit() + await self._dispatch_webhook(session, "certificate.revoked", certificate) + + if self._scheduler is not None: + self._scheduler.remove_certificate(certificate_id) + + async def request_manual_renewal(self, certificate_id: uuid.UUID) -> Certificate: + """Record a manual renewal request without doing DNS work inline.""" + async with self._session_factory() as session: + certificate = await session.get(Certificate, certificate_id) + if certificate is None: + raise CertificateNotFoundError("Certificate not found.") + if certificate.status != CertificateStatus.VALID: + raise CertificateNotRenewableError("Certificate is not renewable.") + + await self._record_event( + session, + certificate, + "certificate.renewal_requested", + {"name": certificate.name}, + ) + await session.commit() + await session.refresh(certificate) + return certificate + + async def _mark_failed( + self, + session: AsyncSession, + certificate: Certificate, + error: Exception, + ) -> None: + certificate.status = CertificateStatus.FAILED + await self._record_event( + session, + certificate, + "certificate.failed", + { + "category": _error_category(error), + "error": str(error), + }, + ) + await session.commit() + await session.refresh(certificate) + await self._dispatch_webhook(session, "certificate.failed", certificate) + + async def _record_event( + self, + session: AsyncSession, + certificate: Certificate, + event_type: str, + details: dict[str, object], + ) -> None: + session.add( + Event( + event_type=event_type, + certificate_id=certificate.id, + details=details, + ) + ) + + async def _dispatch_webhook( + self, + session: AsyncSession, + event_type: str, + certificate: Certificate, + ) -> None: + if self._webhook_dispatcher_factory is None: + return + async with self._webhook_dispatcher_factory(session) as dispatcher: + await dispatcher.dispatch_certificate_event(event_type, certificate) + + def _dns_provider(self, name: str) -> DnsProviderConfig: + for provider in self._settings.dns_providers: + if provider.name == name: + return provider + raise DeploymentError(f"DNS provider not configured: {name}") + + def _acme_account(self, name: str) -> AcmeAccountConfig: + for account in self._settings.acme_accounts: + if account.name == name: + return account + raise DeploymentError(f"ACME account not configured: {name}") + + +async def expiring_event_exists( + session: AsyncSession, + certificate_id: uuid.UUID, +) -> bool: + """Return true when an expiring event was already recorded for a certificate.""" + result = await session.execute( + select(Event).where( + Event.certificate_id == certificate_id, + Event.event_type == "certificate.expiring", + ) + ) + return result.scalar_one_or_none() is not None + + +def _account_key_path(account: AcmeAccountConfig) -> str | None: + if account.account_key_path is None: + return None + return str(Path(account.account_key_path)) + + +def _error_category(error: Exception) -> str: + if isinstance(error, TransientAcmeShError): + return "transient" + if isinstance(error, AcmeShError): + return "terminal" + return "deployment" diff --git a/docs/plan.md b/docs/plan.md index fceac66..83794b1 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -2,8 +2,8 @@ > Derived from `docs/outline.md`. Targets Python 3.14, strict mypy, 80% per-file coverage gate. > Phase 0/1 foundation exists: package skeleton, config loading, structured logging, request IDs, health endpoint, and tests are wired. -> Phases 2-8 now provide the core DB models, backend abstraction, auth, API routes, atomic deployer, renewal scheduler, and webhook dispatcher. -> Implementation lesson: phases 5-8 built the pieces, but a dedicated lifecycle orchestration pass is needed before readiness/final polish so create/manual-renew/revoke consistently call the backend, deploy artifacts, update state, schedule jobs, and emit all lifecycle webhooks. +> Phases 2-9 now provide the core DB models, backend abstraction, auth, API routes, atomic deployer, renewal scheduler, webhook dispatcher, lifecycle orchestration, and health/readiness probes. +> Implementation lesson: lifecycle behavior belongs behind an application service boundary so FastAPI routes and APScheduler callbacks stay thin while backend calls, artifact deployment, state changes, scheduling, audit events, and webhooks remain testable together. --- @@ -264,6 +264,8 @@ Accounts and providers are exposed through read-only API endpoints, but are not **Acceptance:** Create, scheduled renew, manual renew, revoke, deploy, audit events, and webhooks work end-to-end with the mock backend; real acme.sh integration remains covered by subprocess-level tests and optional staging/Pebble tests. +**Implementation note:** Creation now creates a pending row, queues issuance, calls the configured ACME backend with DNS-01 account/provider settings, deploys successful artifacts, transitions to `Valid` or `Failed`, schedules renewal, and emits lifecycle audit/webhook events. Manual renewals queue the same scheduler renewal path, which deploys renewed artifacts on success. Soft delete marks rows `Revoked`, removes scheduler jobs, and emits `certificate.revoked`. Startup renewal reconstruction emits one non-duplicated `certificate.expiring` event for certificates already inside the renewal window. + --- ## Phase 9 — Readiness Checks @@ -278,6 +280,8 @@ Accounts and providers are exposed through read-only API endpoints, but are not **Deferred:** Prometheus-compatible metrics are useful but not part of the immediate v1 path. See `docs/future.md`. +**Implementation note:** `/health` is a liveness probe that returns status and process uptime. `/ready` checks SQLite connectivity and the configured `acme.sh` binary path, returning HTTP 503 with per-check details when either dependency is unavailable. + --- ## Phase 10 — Docker Container & Deployment diff --git a/tests/test_api_phase5.py b/tests/test_api_phase5.py index 67d567b..19d306a 100644 --- a/tests/test_api_phase5.py +++ b/tests/test_api_phase5.py @@ -2,11 +2,13 @@ from __future__ import annotations +import datetime as dt from pathlib import Path from fastapi import FastAPI from fastapi.testclient import TestClient +from acme_api.backend.dataclasses import CertExpiry, IssuanceResult from acme_api.config import ( AcmeAccountConfig, AcmeConfig, @@ -18,6 +20,69 @@ from acme_api.main import create_app +class _ArtifactBackend: + """Test backend that writes deployable certificate artifacts.""" + + def __init__(self, root: Path) -> None: + self._root = root + self.issue_calls = 0 + self.renew_calls = 0 + + async def register_account(self, email: str, server_url: str) -> object: + raise NotImplementedError + + async def issue_certificate( + self, + domains: list[str], + method: str, + challenge_params: dict[str, object], + account_key_path: str | None = None, + ) -> IssuanceResult: + del method, challenge_params + self.issue_calls += 1 + return self._result(domains, "issue", account_key_path) + + async def renew_certificate( + self, + domains: list[str], + force_renewal: bool = False, + ) -> IssuanceResult: + del force_renewal + self.renew_calls += 1 + return self._result(domains, "renew", "account.key") + + async def get_certificate_expiry(self, cert_path: str) -> CertExpiry: + raise NotImplementedError + + def _result( + self, + domains: list[str], + operation: str, + account_key_path: str | None, + ) -> IssuanceResult: + directory = self._root / operation / str(self.issue_calls + self.renew_calls) + directory.mkdir(parents=True, exist_ok=True) + paths = { + "cert": directory / "cert.pem", + "key": directory / "privkey.pem", + "chain": directory / "chain.pem", + "fullchain": directory / "fullchain.pem", + } + for name, path in paths.items(): + path.write_text(f"{operation}-{name}", encoding="utf-8") + return IssuanceResult( + account_key_path=account_key_path or "account.key", + cert=CertExpiry( + cert_path=str(paths["cert"]), + privkey_path=str(paths["key"]), + chain_path=str(paths["chain"]), + fullchain_path=str(paths["fullchain"]), + expires_at=dt.datetime.now(dt.timezone.utc) + dt.timedelta(days=90), + ), + domains=domains, + ) + + def _make_app(tmp_path: Path) -> FastAPI: env_file = tmp_path / "cloudflare.env" env_file.write_text("CF_Token=test\n", encoding="utf-8") @@ -39,7 +104,9 @@ def _make_app(tmp_path: Path) -> FastAPI: "readonly": "readonly-key-12345", }, ) - return create_app(settings=settings) + app = create_app(settings=settings) + app.state.acme_backend = _ArtifactBackend(tmp_path / "acme-artifacts") + return app def test_certificate_lifecycle_endpoints(tmp_path: Path) -> None: @@ -68,12 +135,14 @@ def test_certificate_lifecycle_endpoints(tmp_path: Path) -> None: detail = client.get(f"/v1/certificates/{certificate_id}", headers=headers) assert detail.status_code == 200 assert detail.json()["name"] == "example-cert" + assert detail.json()["status"] == "valid" + assert (tmp_path / "certs" / "example.com" / "fullchain.pem").is_file() renewed = client.post( f"/v1/certificates/{certificate_id}/renew", headers=headers ) assert renewed.status_code == 202 - assert renewed.json()["status"] == "renewing" + assert renewed.json()["status"] == "valid" deleted = client.delete(f"/v1/certificates/{certificate_id}", headers=headers) assert deleted.status_code == 204 diff --git a/tests/test_health.py b/tests/test_health.py index 3fadde6..6369ade 100644 --- a/tests/test_health.py +++ b/tests/test_health.py @@ -11,4 +11,5 @@ async def test_health_returns_ok(client: AsyncClient) -> None: """GET /health should return 200 with status ok.""" response = await client.get("/health") assert response.status_code == 200 - assert response.json() == {"status": "ok"} + assert response.json()["status"] == "ok" + assert isinstance(response.json()["uptime_seconds"], float) diff --git a/tests/test_main.py b/tests/test_main.py index 1f851ab..4a93e41 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -36,7 +36,37 @@ def test_health_endpoint_ok(self, settings: AppSettings) -> None: with TestClient(app) as client: resp = client.get("/health") assert resp.status_code == 200 - assert resp.json() == {"status": "ok"} + assert resp.json()["status"] == "ok" + assert isinstance(resp.json()["uptime_seconds"], float) + + def test_ready_endpoint_ok(self, settings: AppSettings, tmp_path: Path) -> None: + acme_binary = tmp_path / "acme.sh" + acme_binary.write_text("#!/bin/sh\n", encoding="utf-8") + acme_binary.chmod(0o755) + settings.acme.binary_path = str(acme_binary) + app = create_app(settings=settings) + + with TestClient(app) as client: + resp = client.get("/ready") + + assert resp.status_code == 200 + assert resp.json()["status"] == "ready" + assert resp.json()["checks"]["database"]["ok"] is True + assert resp.json()["checks"]["acme_binary"]["ok"] is True + + def test_ready_endpoint_reports_missing_acme_binary( + self, settings: AppSettings, tmp_path: Path + ) -> None: + settings.acme.binary_path = str(tmp_path / "missing-acme.sh") + app = create_app(settings=settings) + + with TestClient(app) as client: + resp = client.get("/ready") + + assert resp.status_code == 503 + assert resp.json()["status"] == "not_ready" + assert resp.json()["checks"]["database"]["ok"] is True + assert resp.json()["checks"]["acme_binary"]["ok"] is False def test_settings_stored_on_state(self, settings: AppSettings) -> None: app = create_app(settings=settings) diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index 81d2682..3934377 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -170,6 +170,35 @@ async def test_rebuild_jobs_schedules_valid_certificates( assert scheduler.get_job(f"renew:{certificate.id}") is not None +@pytest.mark.anyio +async def test_rebuild_jobs_records_expiring_event_once( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + """Startup reconstruction emits one expiring event inside the renewal window.""" + await _create_certificate( + session_factory, + expiry=dt.datetime.now(dt.timezone.utc) + dt.timedelta(days=5), + ) + renewal_scheduler = RenewalScheduler( + session_factory=session_factory, + backend=RecordingBackend(), + config=RenewalConfig(window_days=30), + scheduler=AsyncIOScheduler(timezone=dt.timezone.utc), + ) + + assert await renewal_scheduler.rebuild_jobs() == 1 + assert await renewal_scheduler.rebuild_jobs() == 1 + + async with session_factory() as session: + events = ( + await session.execute( + select(Event).where(Event.event_type == "certificate.expiring") + ) + ).scalars().all() + + assert len(events) == 1 + + @pytest.mark.anyio async def test_successful_renewal_updates_status_and_records_attempt( session_factory: async_sessionmaker[AsyncSession], From 006cbb301e61ec034a3b1b12aa3e2eed8c1d52a9 Mon Sep 17 00:00:00 2001 From: Streaky Date: Thu, 2 Jul 2026 18:09:59 +0100 Subject: [PATCH 17/26] Done to phase 11 --- .dockerignore | 14 +++ Dockerfile | 50 +++++++++ Makefile | 2 +- README.md | 187 +++++++++++++++---------------- acme_api/main.py | 36 ++++-- acme_api/routers/certificates.py | 14 +++ acme_api/routers/config.py | 2 + acme_api/routers/events.py | 7 +- docker-compose.yml | 30 +++++ docker/config.yaml | 33 ++++++ docker/entrypoint.sh | 19 ++++ docs/plan.md | 4 + tests/test_main.py | 32 ++++++ 13 files changed, 325 insertions(+), 105 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 docker/config.yaml create mode 100644 docker/entrypoint.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1c22ec3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +.git +.mypy_cache +.pytest_cache +.pycache +.venv +__pycache__ +*.pyc +*.egg-info +.coverage +coverage-data +data +act +act_*.tar.gz + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d457df2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,50 @@ +FROM python:3.14-slim AS builder + +ENV PIP_NO_CACHE_DIR=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /build + +COPY requirements.txt ./ +RUN python -m venv /opt/venv \ + && /opt/venv/bin/pip install --upgrade pip setuptools wheel \ + && /opt/venv/bin/pip install -r requirements.txt + +COPY pyproject.toml README.md ./ +COPY acme_api ./acme_api +RUN /opt/venv/bin/pip install --no-deps . + +FROM python:3.14-slim AS runner + +ENV ACME_API_CONFIG=/config/config.yaml \ + ACME_API_HOST=0.0.0.0 \ + ACME_API_PORT=8080 \ + PATH="/opt/venv/bin:/home/acmeapi/.local/bin:${PATH}" \ + PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl openssl socat \ + && rm -rf /var/lib/apt/lists/* \ + && useradd --create-home --home-dir /home/acmeapi --shell /usr/sbin/nologin acmeapi \ + && mkdir -p /config /data /certificates /acmesh /home/acmeapi/.local/bin \ + && chown -R acmeapi:acmeapi /config /data /certificates /acmesh /home/acmeapi + +COPY --from=builder /opt/venv /opt/venv +COPY docker/entrypoint.sh /usr/local/bin/acme-api-entrypoint + +RUN chmod +x /usr/local/bin/acme-api-entrypoint + +USER acmeapi +WORKDIR /app + +EXPOSE 8080 +VOLUME ["/config", "/data", "/certificates", "/acmesh"] + +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD curl -fsS "http://127.0.0.1:${ACME_API_PORT}/health" >/dev/null || exit 1 + +ENTRYPOINT ["acme-api-entrypoint"] +CMD ["acme-api"] + diff --git a/Makefile b/Makefile index badecb1..5c07861 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: venv deps dev start test typecheck lint flake8 format isort isort-fix combined-check install-act simulate-ci deps-update +.PHONY: venv pip-cache-dir deps dev start build stop logs test typecheck lint flake8 format isort isort-fix check-max-lines combined-check install-act simulate-ci deps-update .ONESHELL: export ROOT_PATH=$(abspath $(dir $(lastword $(MAKEFILE_LIST)))) diff --git a/README.md b/README.md index d08b9da..32b022d 100644 --- a/README.md +++ b/README.md @@ -1,123 +1,106 @@ # acme.api -A lightweight, self-hosted REST service for managing ACME certificates — backed by `acme.sh`. +Lightweight, self-hosted REST service for managing ACME certificates through a modern API while delegating ACME protocol work to `acme.sh`. -## Philosophy +## Status -**acme.api is not another ACME client.** It's a certificate management service with a clean REST interface that delegates all protocol work to mature tooling. By separating the infrastructure API from the ACME implementation, applications integrate once and remain independent of the underlying ACME client. +Prototype v1 implementation. The core API, SQLite state, API key auth, `acme.sh` backend wrapper, atomic certificate deployment, renewal scheduler, lifecycle webhooks, health/readiness probes, and Docker packaging are implemented. End-to-end staging/Pebble integration tests are still planned. ## Quick Start +Build and start the local container: + ```sh -docker run -d \ - --name acme-api \ - -v /path/to/certs:/certificates \ - -v /path/acme.sh-data:/acmesh \ - -p 8080:8080 \ - ghcr.io/acme.api/acme.api:latest +make build +make start +curl http://localhost:8080/health +curl http://localhost:8080/ready ``` -Three persistent volumes — `/data`, `/certificates`, `/acmesh` — each with a distinct purpose. See the [deployment section](#deployment) for details. +The compose file uses named volumes for persistent runtime state: -Then interact via the REST API: +| Volume | Container path | Purpose | +|---|---|---| +| `acme-api-data` | `/data` | SQLite database | +| `acme-api-certificates` | `/certificates` | Atomically deployed certificate files | +| `acme-api-acmesh` | `/acmesh` | `acme.sh` account and certificate state | -```sh -# Check readiness -curl http://localhost:8080/ready +The bundled compose config at `docker/config.yaml` is intentionally minimal so the service can boot for health checks. For real issuance, copy `config.example.yaml`, configure ACME accounts, DNS provider aliases, API keys, and credential file mounts, then set `ACME_API_CONFIG` to that file inside the container. + +## Local Development -# List certificates -curl http://localhost:8080/v1/certificates +```sh +make dev +make combined-check ``` -## Features +Useful targets: -| Area | What's included (v1) | +| Command | Description | |---|---| -| **Certificate lifecycle** | Issue, renew, revoke via REST API | -| **Validation** | DNS-01 with persistent provider mode — credentials configured once by admin | -| **ACME backend** | `acme.sh` — extensible to lego or native later without API changes | -| **Accounts** | Manage multiple CA accounts (Let's Encrypt prod/staging, ZeroSSL) | -| **Renewals** | Automatic before expiry with tracked state machine | -| **Webhooks** | Events on issue/renew/fail/expiry/revocation | -| **Logging** | Structured JSON to stdout and file | -| **Auth (v1)** | API key authentication with RBAC roles: Admin, Operator, Read Only | +| `make test` | Run pytest with coverage and the per-file coverage gate | +| `make typecheck` | Run strict mypy | +| `make lint` | Run flake8 and pylint | +| `make isort` | Check import ordering | +| `make build` | Build the Docker image | +| `make start` | Start the Docker compose service | +| `make stop` | Stop the Docker compose service | +| `make logs` | Follow container logs | -### Out of scope (v1) +## Configuration -HTTP-01, TLS-ALPN-01, per-request DNS credentials, web UI, HA/clustering. +Configuration is YAML. By default the app loads `./config.yaml`; set `ACME_API_CONFIG=/path/to/config.yaml` to override it. See `config.example.yaml` for a complete reference. -## REST API +Certificate issuance requires: -Full OpenAPI spec is generated from the codebase. Key endpoints: +- one or more `acme_accounts` +- one or more `dns_providers` +- a DNS provider env file readable by the container +- at least one bootstrap API key in `api_keys` -| Method | Path | Description | -|---|---|---| -| `POST` | `/v1/certificates` | Register and issue a certificate | -| `GET` | `/v1/certificates` | List all certificates | -| `GET` | `/v1/certificates/{id}` | Get certificate details | -| `DELETE` | `/v1/certificates/{id}` | Revoke and remove | -| `POST` | `/v1/certificates/{id}/renew` | Force renewal | -| `GET` | `/v1/accounts` | List ACME accounts | -| `GET` | `/v1/providers` | List DNS providers | -| `GET` | `/v1/events` | Event history | -| `GET` | `/health` | Liveness probe | -| `GET` | `/ready` | Readiness probe | - -### Example: issue a certificate +Example certificate request: ```json -POST /v1/certificates - { - "name": "mail.example.com", + "name": "wildcard-example", "domains": ["*.example.com", "example.com"], - "dns_provider": "production", - "account": "letsencrypt-production" + "acme_account_ref": "letsencrypt-production", + "dns_provider_ref": "production", + "key_algorithm": "ecdsa" } ``` -### Certificate key algorithms - -- RSA (default) -- ECDSA P-256 -- ECDSA P-384 - -## Architecture - -``` - REST API - │ - ┌───────────────┴───────────────┐ - │ │ - Certificate Manager Renewal Scheduler - │ │ - └───────────────┬───────────────┘ - │ - ACME Backend - │ - acme.sh - │ - Let's Encrypt / ZeroSSL / Buypass -``` +## REST API -The public API is backend-independent. The ACME implementation (currently `acme.sh`) is an internal detail. +OpenAPI is generated at `/openapi.json`; Swagger UI is available at `/docs`. -## Deployment +| Method | Path | Auth | Description | +|---|---|---|---| +| `GET` | `/health` | none | Liveness probe with uptime | +| `GET` | `/ready` | none | DB and `acme.sh` readiness | +| `POST` | `/v1/certificates` | operator | Create and queue issuance | +| `GET` | `/v1/certificates` | readonly | List certificates | +| `GET` | `/v1/certificates/{id}` | readonly | Read certificate details | +| `POST` | `/v1/certificates/{id}/renew` | operator | Queue manual renewal | +| `DELETE` | `/v1/certificates/{id}` | operator | Soft-delete as revoked | +| `GET` | `/v1/accounts` | readonly | List configured ACME accounts | +| `GET` | `/v1/providers` | readonly | List configured DNS providers | +| `GET` | `/v1/events` | readonly | Query audit events | -### Docker volumes +Authenticated requests use bearer API keys: -| Mount | Purpose | Survives upgrades? | -|---|---|---| -| `/data` | SQLite database — certs, accounts, providers, webhooks, audit log | Yes | -| `/certificates` | Issued certificate files — atomic deployment via rename | Yes | -| `/acmesh` | `acme.sh` home directory and account state | Yes | +```sh +curl \ + -H "Authorization: Bearer $ACME_API_KEY" \ + http://localhost:8080/v1/certificates +``` -### Shared filesystem model +## Certificate Deployment -Certificates are written atomically: temporary files → flush → rename. Consumers never see partially-written certificates. Layout per domain: +Successful issuance and renewal deploy artifacts under the first requested domain: -``` -/certificates/mail.example.com/ +```text +/certificates/example.com/ cert.pem chain.pem fullchain.pem @@ -125,20 +108,32 @@ Certificates are written atomically: temporary files → flush → rename. Consu metadata.json ``` -## Design Principles +Files are copied to temporary names, flushed, permissioned, and atomically renamed into place. Default permissions are `0644` for certificate files and `0600` for private keys. + +## Architecture -- **API-first** — everything through REST, no CLI dependency -- **Backend-independent** — swap ACME client without breaking integrations -- **Simple deployment** — one container, three volumes, YAML config -- **Opinionated defaults** — works out of the box with sensible settings -- **Minimal configuration** — DNS providers configured once by admin -- **No Kubernetes requirement** — runs anywhere Docker does -- **No external database** — SQLite is sufficient for v1 +```text + REST API + | + +---------------+---------------+ + | | + Certificate Lifecycle Renewal Scheduler + | | + +---------------+---------------+ + | + ACME Backend + | + acme.sh +``` -## Project status +The public API is independent of the ACME backend. v1 supports DNS-01 through `acme.sh`; future backends can be added behind the same internal protocol. -Prototype. API surface and architecture are defined; implementation underway. See `docs/outline.md` for the v1 specification and `docs/future.md` for deferred ideas. +## Non-Goals For v1 -## License +- HTTP-01 validation +- TLS-ALPN-01 validation +- per-request DNS credentials +- web UI +- high availability or clustering +- implementing the ACME protocol directly -See [LICENSE](LICENSE). diff --git a/acme_api/main.py b/acme_api/main.py index c41ad52..9cb90db 100644 --- a/acme_api/main.py +++ b/acme_api/main.py @@ -7,10 +7,11 @@ from __future__ import annotations import logging +import os import time from contextlib import asynccontextmanager from pathlib import Path -from typing import AsyncGenerator +from typing import Any, AsyncGenerator import uvicorn from fastapi import FastAPI, Request, Response @@ -29,6 +30,12 @@ from acme_api.services.certificates import CertificateLifecycleService from acme_api.webhooks import WebhookDeliverySettings, WebhookDispatcher +_COMMON_OPENAPI_RESPONSES: dict[int | str, dict[str, Any]] = { + 401: {"description": "Missing or invalid API key."}, + 403: {"description": "Authenticated API key does not have the required role."}, + 500: {"description": "Internal server error."}, +} + @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncGenerator[None]: @@ -130,6 +137,7 @@ def create_app(settings: AppSettings | None = None) -> FastAPI: ), version="0.1.0", lifespan=lifespan, + responses=_COMMON_OPENAPI_RESPONSES, ) app.state.settings = settings @@ -141,7 +149,11 @@ def create_app(settings: AppSettings | None = None) -> FastAPI: app.include_router(config_router) app.include_router(events_router) - @app.get("/health", tags=["Health"]) + @app.get( + "/health", + tags=["Health"], + responses={200: {"description": "Process is running."}}, + ) async def health() -> dict[str, str | float]: """Liveness probe — always returns 200 when the process is running.""" return { @@ -149,7 +161,14 @@ async def health() -> dict[str, str | float]: "uptime_seconds": round(time.monotonic() - app.state.started_at, 3), } - @app.get("/ready", tags=["Health"]) + @app.get( + "/ready", + tags=["Health"], + responses={ + 200: {"description": "Runtime dependencies are available."}, + 503: {"description": "One or more runtime dependencies are unavailable."}, + }, + ) async def ready() -> JSONResponse: """Readiness probe for database and acme.sh executable availability.""" ready_ok, checks = await readiness_status( @@ -176,18 +195,21 @@ def main() -> None: """CLI entry point — loads config and launches uvicorn.""" settings = load_config() setup_logging(level=settings.log.level, format_type=settings.log.format) + host = os.environ.get("ACME_API_HOST", "0.0.0.0") + port = int(os.environ.get("ACME_API_PORT", "8000")) logging.getLogger(__name__).info( - "launching uvicorn | host=0.0.0.0 port=%s level=%s", - 8000, + "launching uvicorn | host=%s port=%s level=%s", + host, + port, settings.log.level.lower(), ) uvicorn.run( "acme_api.main:create_app", factory=True, - host="0.0.0.0", - port=8000, + host=host, + port=port, log_level=settings.log.level.lower(), ) diff --git a/acme_api/routers/certificates.py b/acme_api/routers/certificates.py index e382a78..033a350 100644 --- a/acme_api/routers/certificates.py +++ b/acme_api/routers/certificates.py @@ -3,6 +3,7 @@ from __future__ import annotations import uuid +from typing import Any from fastapi import ( APIRouter, @@ -30,12 +31,17 @@ router = APIRouter(prefix="/v1/certificates", tags=["Certificates"]) +_NOT_FOUND_RESPONSE: dict[int | str, dict[str, Any]] = { + 404: {"description": "Certificate not found."} +} + @router.post( "", response_model=CertificateRead, status_code=status.HTTP_202_ACCEPTED, summary="Create a certificate request", + responses={409: {"description": "Certificate name already exists."}}, ) async def create_certificate( payload: CertificateCreate, @@ -61,6 +67,7 @@ async def create_certificate( "", response_model=list[CertificateRead], summary="List certificates", + responses={200: {"description": "Certificate list returned."}}, ) async def list_certificates( _: object = Depends(require_readonly), @@ -84,6 +91,7 @@ async def list_certificates( "/{certificate_id}", response_model=CertificateRead, summary="Get certificate detail", + responses=_NOT_FOUND_RESPONSE, ) async def get_certificate( certificate_id: uuid.UUID, @@ -104,6 +112,7 @@ async def get_certificate( "/{certificate_id}", status_code=status.HTTP_204_NO_CONTENT, summary="Revoke a certificate record", + responses=_NOT_FOUND_RESPONSE, ) async def revoke_certificate( certificate_id: uuid.UUID, @@ -126,6 +135,11 @@ async def revoke_certificate( response_model=CertificateRead, status_code=status.HTTP_202_ACCEPTED, summary="Trigger certificate renewal", + responses={ + **_NOT_FOUND_RESPONSE, + 409: {"description": "Certificate is not renewable."}, + 503: {"description": "Renewal scheduler is unavailable."}, + }, ) async def renew_certificate( certificate_id: uuid.UUID, diff --git a/acme_api/routers/config.py b/acme_api/routers/config.py index 3a37d2a..e3d365c 100644 --- a/acme_api/routers/config.py +++ b/acme_api/routers/config.py @@ -22,6 +22,7 @@ def get_settings(request: Request) -> AppSettings: "/accounts", response_model=list[AcmeAccountRead], summary="List ACME accounts", + responses={200: {"description": "Configured ACME account aliases returned."}}, ) async def list_acme_accounts( _: object = Depends(require_readonly), @@ -38,6 +39,7 @@ async def list_acme_accounts( "/providers", response_model=list[DnsProviderRead], summary="List DNS providers", + responses={200: {"description": "Configured DNS provider aliases returned."}}, ) async def list_dns_providers( _: object = Depends(require_readonly), diff --git a/acme_api/routers/events.py b/acme_api/routers/events.py index f8210d1..45d3e37 100644 --- a/acme_api/routers/events.py +++ b/acme_api/routers/events.py @@ -17,7 +17,12 @@ router = APIRouter(prefix="/v1/events", tags=["Events"]) -@router.get("", response_model=list[EventRead], summary="List audit events") +@router.get( + "", + response_model=list[EventRead], + summary="List audit events", + responses={200: {"description": "Audit events returned."}}, +) async def list_events( # pylint: disable=too-many-arguments,too-many-positional-arguments _: object = Depends(require_readonly), db_session: AsyncSession = Depends(get_db_session), diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..966a88a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,30 @@ +services: + acme-api: + build: + context: . + dockerfile: Dockerfile + image: acme-api:local + environment: + ACME_API_CONFIG: /config/config.yaml + ACME_API_HOST: 0.0.0.0 + ACME_API_PORT: "8080" + ports: + - 8080:8080 + volumes: + - ./docker/config.yaml:/config/config.yaml:ro + - acme-api-data:/data + - acme-api-certificates:/certificates + - acme-api-acmesh:/acmesh + healthcheck: + test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 20s + restart: unless-stopped + +volumes: + acme-api-data: + acme-api-certificates: + acme-api-acmesh: + diff --git a/docker/config.yaml b/docker/config.yaml new file mode 100644 index 0000000..fc4d698 --- /dev/null +++ b/docker/config.yaml @@ -0,0 +1,33 @@ +log: + level: INFO + format: json + +database: + url: sqlite+aiosqlite:////data/acme.db + pool_size: 5 + +deployment: + directory: /certificates + permissions_cert: 420 + permissions_key: 384 + +acme: + binary_path: /home/acmeapi/.local/bin/acme.sh + home_dir: /acmesh + +renewal: + enabled: true + check_interval_hours: 24 + window_days: 30 + max_retries: 3 + shutdown_timeout_seconds: 30 + +webhooks: + timeout_seconds: 5.0 + max_retries: 3 + backoff_seconds: 1.0 + +api_keys: {} +dns_providers: [] +acme_accounts: [] + diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000..dd96c60 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,19 @@ +#!/bin/sh +set -eu + +ACME_SH_PATH="${ACME_SH_PATH:-/home/acmeapi/.local/bin/acme.sh}" +ACME_SH_HOME="${ACME_SH_HOME:-/home/acmeapi/.acme.sh}" + +mkdir -p /config /data /certificates /acmesh "$(dirname "$ACME_SH_PATH")" + +if [ ! -x "$ACME_SH_PATH" ]; then + tmp_dir="$(mktemp -d)" + trap 'rm -rf "$tmp_dir"' EXIT + curl -fsSL https://github.com/acmesh-official/acme.sh/archive/master.tar.gz \ + | tar -xz -C "$tmp_dir" --strip-components=1 + cd "$tmp_dir" + HOME=/home/acmeapi sh ./acme.sh --install --nocron --home "$ACME_SH_HOME" + ln -sf "$ACME_SH_HOME/acme.sh" "$ACME_SH_PATH" +fi + +exec "$@" diff --git a/docs/plan.md b/docs/plan.md index 83794b1..e9b0bea 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -303,6 +303,8 @@ Accounts and providers are exposed through read-only API endpoints, but are not **Acceptance:** `make build start` produces a running container; health endpoint accessible; volumes persist data across restarts; certificates are deployed to the mounted filesystem. +**Implementation note:** Docker packaging uses a multi-stage Python 3.14 slim image, a non-root `acmeapi` user, named compose volumes for `/data`, `/certificates`, and `/acmesh`, and an entrypoint that installs `acme.sh` into the app user's home on first start if missing. The compose config boots with an intentionally minimal `/config/config.yaml` for smoke checks; real issuance requires mounting a populated config and DNS credential files. + --- ## Phase 11 — OpenAPI Documentation & Final Polish @@ -316,6 +318,8 @@ Accounts and providers are exposed through read-only API endpoints, but are not **Acceptance:** OpenAPI docs are comprehensive; Swagger UI interactive; all quality gates pass at 80%+ coverage per file; mypy strict mode clean. +**Implementation note:** FastAPI serves Swagger UI at `/docs` and OpenAPI at `/openapi.json`. Routes include explicit response metadata for common auth failures and lifecycle-specific errors. README now documents local development, Docker compose deployment, configuration, API key auth, endpoint summary, and certificate file layout. + --- ## Phase 12 — Integration Tests & End-to-End Verification diff --git a/tests/test_main.py b/tests/test_main.py index 4a93e41..8ecd603 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -68,6 +68,19 @@ def test_ready_endpoint_reports_missing_acme_binary( assert resp.json()["checks"]["database"]["ok"] is True assert resp.json()["checks"]["acme_binary"]["ok"] is False + def test_openapi_documents_health_and_certificate_errors( + self, settings: AppSettings + ) -> None: + app = create_app(settings=settings) + + with TestClient(app) as client: + spec = client.get("/openapi.json").json() + + assert "/ready" in spec["paths"] + assert "503" in spec["paths"]["/ready"]["get"]["responses"] + certificate_post = spec["paths"]["/v1/certificates"]["post"] + assert "409" in certificate_post["responses"] + def test_settings_stored_on_state(self, settings: AppSettings) -> None: app = create_app(settings=settings) stored = getattr(app.state, "settings", None) @@ -112,6 +125,7 @@ def test_main_runs_uvicorn(self, tmp_path: Path) -> None: with ( patch("acme_api.main.load_config", return_value=AppSettings()), patch("uvicorn.run") as mock_run, + patch.dict("os.environ", {}, clear=True), ): from acme_api.main import main @@ -121,3 +135,21 @@ def test_main_runs_uvicorn(self, tmp_path: Path) -> None: call_kwargs = mock_run.call_args.kwargs assert call_kwargs["factory"] is True assert call_kwargs["port"] == 8000 + + def test_main_honors_host_and_port_env(self) -> None: + with ( + patch("acme_api.main.load_config", return_value=AppSettings()), + patch("uvicorn.run") as mock_run, + patch.dict( + "os.environ", + {"ACME_API_HOST": "127.0.0.1", "ACME_API_PORT": "8080"}, + clear=True, + ), + ): + from acme_api.main import main + + main() + + call_kwargs = mock_run.call_args.kwargs + assert call_kwargs["host"] == "127.0.0.1" + assert call_kwargs["port"] == 8080 From aee5ecd7900b2dc3bb983e91bb2d980fb48de79c Mon Sep 17 00:00:00 2001 From: Streaky Date: Fri, 3 Jul 2026 01:11:51 +0100 Subject: [PATCH 18/26] phase 12/12.5 --- .github/workflows/ci.yml | 34 +++ docs/plan.md | 4 + tests/fixtures/sample_config.yaml | 24 ++ tests/fixtures/sample_dns.env | 1 + tests/integration/test_e2e_lifecycle.py | 354 ++++++++++++++++++++++++ 5 files changed, 417 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 tests/fixtures/sample_config.yaml create mode 100644 tests/fixtures/sample_dns.env create mode 100644 tests/integration/test_e2e_lifecycle.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..000ca97 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +on: + push: + branches: + - main + - master + pull_request: + workflow_dispatch: + +jobs: + quality: + name: Quality Gates + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.14" + cache: pip + cache-dependency-path: | + requirements.txt + requirements-dev.txt + + - name: Install dependencies + run: make dev + + - name: Run local release gates + run: make combined-check diff --git a/docs/plan.md b/docs/plan.md index e9b0bea..31602cb 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -336,6 +336,8 @@ Accounts and providers are exposed through read-only API endpoints, but are not **Acceptance:** Integration tests run via `make test`; coverage gate met; default E2E flow works with mock/Pebble-compatible backends; real staging ACME tests are optional and gated on DNS credentials. +**Implementation note:** `tests/integration/` now covers the default mock-backed E2E flow through FastAPI, SQLite, background issuance/renewal, atomic deployment, audit events, webhook delivery through an in-memory HTTP transport, scheduler startup reconstruction for expiring certificates, and endpoint RBAC. Fixture config and DNS env samples live under `tests/fixtures/`. Real ACME staging/Pebble and Docker smoke flows remain optional/gated so `make test` stays deterministic. + --- ## Phase 12.5 — Continuous Integration @@ -361,6 +363,8 @@ Accounts and providers are exposed through read-only API endpoints, but are not **Acceptance:** GitHub Actions workflow is committed, `make simulate-ci` passes locally, and the remote workflow passes on the branch/PR. +**Implementation note:** `.github/workflows/ci.yml` runs on pushes, pull requests, and manual dispatch with Python 3.14, installs from the pinned requirements through `make dev`, and executes `make combined-check`. Local `make simulate-ci` is wired through `act`; it requires the documented `ACT_VERSION`, `ACT_PLATFORM`, and `ACT_IMAGE` settings. + --- ## Dependency Graph & Parallelism diff --git a/tests/fixtures/sample_config.yaml b/tests/fixtures/sample_config.yaml new file mode 100644 index 0000000..0eb482a --- /dev/null +++ b/tests/fixtures/sample_config.yaml @@ -0,0 +1,24 @@ +log: + level: INFO + format: json +database: + url: sqlite+aiosqlite:///./data/acme.db +deployment: + directory: /certificates +acme: + binary_path: /usr/local/bin/acme.sh + home_dir: /acmesh +renewal: + enabled: true + window_days: 30 +dns_providers: + - name: cloudflare-main + provider_name: cloudflare + env_vars_file_path: tests/fixtures/sample_dns.env +acme_accounts: + - name: letsencrypt-staging + server_url: https://acme-staging-v02.api.letsencrypt.org/directory +api_keys: + admin: admin-key-12345 + operator: operator-key-12345 + readonly: readonly-key-12345 diff --git a/tests/fixtures/sample_dns.env b/tests/fixtures/sample_dns.env new file mode 100644 index 0000000..c9db762 --- /dev/null +++ b/tests/fixtures/sample_dns.env @@ -0,0 +1 @@ +CF_Token=test-token diff --git a/tests/integration/test_e2e_lifecycle.py b/tests/integration/test_e2e_lifecycle.py new file mode 100644 index 0000000..002b750 --- /dev/null +++ b/tests/integration/test_e2e_lifecycle.py @@ -0,0 +1,354 @@ +"""End-to-end integration coverage for the default mock-backed release gate.""" + +from __future__ import annotations + +import datetime as dt +from pathlib import Path +from typing import Any, ClassVar + +import httpx2 +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +import acme_api.main as main_module +from acme_api.backend.dataclasses import AccountInfo, CertExpiry, IssuanceResult +from acme_api.backend.protocol import ChallengeMethod +from acme_api.config import ( + AcmeAccountConfig, + AcmeConfig, + AppSettings, + DatabaseConfig, + DeploymentConfig, + DnsProviderConfig, + RenewalConfig, + WebhookDeliveryConfig, +) +from acme_api.db import get_session_factory, init_db, init_engine +from acme_api.main import create_app +from acme_api.models.certificate import Certificate, CertificateStatus +from acme_api.models.event import Event +from acme_api.models.webhook import WebhookConfig +from acme_api.scheduler import RenewalScheduler +from acme_api.webhooks import SIGNATURE_HEADER, WebhookDispatcher, sign_payload + + +class ArtifactBackend: + """Backend double that creates realistic artifact files for deployment.""" + + def __init__(self, root: Path) -> None: + self._root = root + self.issue_calls = 0 + self.renew_calls = 0 + self.challenge_params: list[dict[str, Any]] = [] + + async def register_account(self, email: str, server_url: str) -> AccountInfo: + """Account registration is not part of this E2E flow.""" + return AccountInfo( + key_path="account.key", + email=email, + server_url=server_url, + ) + + async def issue_certificate( + self, + domains: list[str], + method: ChallengeMethod, + challenge_params: dict[str, Any], + account_key_path: str | None = None, + ) -> IssuanceResult: + """Return deployable certificate artifacts for issuance.""" + assert method == "dns-01" + self.issue_calls += 1 + self.challenge_params.append(challenge_params) + return self._result(domains, "issue", account_key_path) + + async def renew_certificate( + self, + domains: list[str], + force_renewal: bool = False, + ) -> IssuanceResult: + """Return deployable certificate artifacts for renewal.""" + assert force_renewal is False + self.renew_calls += 1 + return self._result(domains, "renew", "account.key") + + async def get_certificate_expiry(self, cert_path: str) -> CertExpiry: + """Return deterministic expiry metadata for a certificate path.""" + return CertExpiry( + cert_path=cert_path, + privkey_path=cert_path, + chain_path=cert_path, + fullchain_path=cert_path, + expires_at=dt.datetime.now(dt.timezone.utc) + dt.timedelta(days=90), + ) + + def _result( + self, + domains: list[str], + operation: str, + account_key_path: str | None, + ) -> IssuanceResult: + directory = self._root / operation / str(self.issue_calls + self.renew_calls) + directory.mkdir(parents=True, exist_ok=True) + paths = { + "cert": directory / "cert.pem", + "key": directory / "privkey.pem", + "chain": directory / "chain.pem", + "fullchain": directory / "fullchain.pem", + } + for name, path in paths.items(): + path.write_text(f"{operation}-{name}", encoding="utf-8") + return IssuanceResult( + account_key_path=account_key_path or "account.key", + cert=CertExpiry( + cert_path=str(paths["cert"]), + privkey_path=str(paths["key"]), + chain_path=str(paths["chain"]), + fullchain_path=str(paths["fullchain"]), + expires_at=dt.datetime.now(dt.timezone.utc) + dt.timedelta(days=90), + ), + domains=domains, + ) + + +class CapturingWebhookDispatcher(WebhookDispatcher): + """Webhook dispatcher that sends through an in-memory HTTP transport.""" + + requests: ClassVar[list[httpx2.Request]] = [] + + def __init__( + self, + session: AsyncSession, + settings: WebhookDeliveryConfig | None = None, + ) -> None: + del settings + client = httpx2.AsyncClient(transport=httpx2.MockTransport(self._handle)) + super().__init__(session=session, client=client) + + @classmethod + def reset(cls) -> None: + """Clear captured webhook requests.""" + cls.requests = [] + + @classmethod + def _handle(cls, request: httpx2.Request) -> httpx2.Response: + cls.requests.append(request) + return httpx2.Response(status_code=204) + + +def _settings(tmp_path: Path) -> AppSettings: + fixture_dir = Path(__file__).resolve().parents[1] / "fixtures" + return AppSettings( + database=DatabaseConfig(url=f"sqlite+aiosqlite:///{tmp_path}/test.db"), + deployment=DeploymentConfig(directory=tmp_path / "certificates"), + acme=AcmeConfig(home_dir=tmp_path / "acmesh"), + renewal=RenewalConfig(window_days=30, max_retries=1), + webhooks=WebhookDeliveryConfig(max_retries=0, backoff_seconds=0), + dns_providers=[ + DnsProviderConfig( + name="cloudflare-main", + provider_name="cloudflare", + env_vars_file_path=fixture_dir / "sample_dns.env", + ) + ], + acme_accounts=[AcmeAccountConfig(name="letsencrypt-staging")], + api_keys={ + "admin": "admin-key-12345", + "operator": "operator-key-12345", + "readonly": "readonly-key-12345", + }, + ) + + +def _make_app(tmp_path: Path) -> FastAPI: + app = create_app(settings=_settings(tmp_path)) + app.state.acme_backend = ArtifactBackend(tmp_path / "acme-artifacts") + return app + + +def _operator_headers() -> dict[str, str]: + return {"Authorization": "Bearer operator-key-12345"} + + +def test_full_certificate_lifecycle_with_webhooks( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Create, issue, deploy, renew, revoke, audit, and deliver webhooks.""" + monkeypatch.setattr(main_module, "WebhookDispatcher", CapturingWebhookDispatcher) + CapturingWebhookDispatcher.reset() + + app = _make_app(tmp_path) + with TestClient(app) as client: + _add_webhook_config(client) + + created = client.post( + "/v1/certificates", + headers=_operator_headers(), + json={ + "name": "e2e-cert", + "domains": ["example.com", "www.example.com"], + "acme_account_ref": "letsencrypt-staging", + "dns_provider_ref": "cloudflare-main", + }, + ) + assert created.status_code == 202 + certificate_id = created.json()["id"] + + detail = client.get( + f"/v1/certificates/{certificate_id}", + headers=_operator_headers(), + ) + assert detail.json()["status"] == "valid" + assert (tmp_path / "certificates" / "example.com" / "fullchain.pem").is_file() + backend = app.state.acme_backend + assert backend.issue_calls == 1 + assert backend.challenge_params == [ + { + "dns_provider": "cloudflare", + "env_vars_file": str(Path("tests/fixtures/sample_dns.env").resolve()), + } + ] + + renewed = client.post( + f"/v1/certificates/{certificate_id}/renew", + headers=_operator_headers(), + ) + assert renewed.status_code == 202 + assert backend.renew_calls == 1 + + revoked = client.delete( + f"/v1/certificates/{certificate_id}", + headers=_operator_headers(), + ) + assert revoked.status_code == 204 + + events = client.get("/v1/events", headers=_operator_headers()).json() + event_types = {event["event_type"] for event in events} + assert { + "certificate.created", + "certificate.issued", + "certificate.renewal_requested", + "certificate.renewed", + "certificate.revoked", + }.issubset(event_types) + + webhook_events = { + request.headers["X-Webhook-Event"] + for request in CapturingWebhookDispatcher.requests + } + assert { + "certificate.created", + "certificate.issued", + "certificate.renewed", + "certificate.revoked", + }.issubset(webhook_events) + for request in CapturingWebhookDispatcher.requests: + assert request.headers[SIGNATURE_HEADER] == sign_payload( + "shared-secret", + request.content, + ) + + +@pytest.mark.anyio +async def test_scheduler_rebuilds_expiring_certificate_jobs(tmp_path: Path) -> None: + """A valid cert inside the renewal window is scheduled and audited once.""" + settings = _settings(tmp_path) + engine = init_engine(settings) + try: + await init_db(engine) + session_factory = get_session_factory() + certificate = await _create_valid_expiring_certificate(session_factory) + scheduler = RenewalScheduler( + session_factory=session_factory, + backend=ArtifactBackend(tmp_path / "acme-artifacts"), + config=settings.renewal, + ) + + assert await scheduler.rebuild_jobs() == 1 + assert await scheduler.rebuild_jobs() == 1 + + async with session_factory() as session: + events = ( + await session.execute( + select(Event).where(Event.event_type == "certificate.expiring") + ) + ).scalars().all() + + assert len(events) == 1 + assert scheduler.schedule_certificate(certificate) is not None + scheduler.remove_certificate(certificate.id) + finally: + await engine.dispose() + + +def test_role_matrix_for_integration_endpoints(tmp_path: Path) -> None: + """Default E2E endpoints enforce API key roles consistently.""" + with TestClient(_make_app(tmp_path)) as client: + readonly = {"Authorization": "Bearer readonly-key-12345"} + operator = _operator_headers() + + assert client.get("/v1/certificates").status_code == 401 + assert client.get("/v1/certificates", headers=readonly).status_code == 200 + assert client.get("/v1/accounts", headers=readonly).status_code == 200 + assert client.get("/v1/providers", headers=readonly).status_code == 200 + assert client.get("/v1/events", headers=readonly).status_code == 200 + assert client.post( + "/v1/certificates", + headers=readonly, + json={ + "name": "forbidden", + "domains": ["forbidden.example.com"], + "acme_account_ref": "letsencrypt-staging", + "dns_provider_ref": "cloudflare-main", + }, + ).status_code == 403 + assert client.post( + "/v1/certificates", + headers=operator, + json={ + "name": "allowed", + "domains": ["allowed.example.com"], + "acme_account_ref": "letsencrypt-staging", + "dns_provider_ref": "cloudflare-main", + }, + ).status_code == 202 + + +def _add_webhook_config(client: TestClient) -> None: + session_factory = get_session_factory() + + async def add_config() -> None: + async with session_factory() as session: + session.add( + WebhookConfig( + url="https://hooks.example.test/certificates", + events=["*"], + secret="shared-secret", + ) + ) + await session.commit() + + assert client.portal is not None + client.portal.call(add_config) + + +async def _create_valid_expiring_certificate( + session_factory: async_sessionmaker[AsyncSession], +) -> Certificate: + async with session_factory() as session: + certificate = Certificate( + name="expiring-e2e", + domains=["expiring.example.com"], + acme_account_ref="letsencrypt-staging", + dns_provider_ref="cloudflare-main", + expiry_date=dt.datetime.now(dt.timezone.utc) + dt.timedelta(days=5), + status=CertificateStatus.VALID, + ) + session.add(certificate) + await session.commit() + await session.refresh(certificate) + return certificate From eae9b956134130c82bcf3b8bdfebc52f7f14f063 Mon Sep 17 00:00:00 2001 From: Streaky Date: Fri, 3 Jul 2026 02:52:06 +0100 Subject: [PATCH 19/26] port implementation cleanup --- .gitignore | 2 ++ AGENTS.md | 7 ++++--- Makefile | 4 ++++ README.md | 13 +++++++++++-- config.example.yaml | 1 + docs/future.md | 16 ++++++++++++++++ docs/plan.md | 10 +++++----- 7 files changed, 43 insertions(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index 5bb9c62..7c69c7a 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,5 @@ coverage-data/ .coverage data/ + +/act diff --git a/AGENTS.md b/AGENTS.md index a602878..84881a5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,8 +14,8 @@ ## Directory Layout ``` -acme_api/ # main package (currently empty — awaiting implementation) -tests/ # pytest test suite; fixtures in tests/fixtures/ +acme_api/ # main package: API, auth, DB, backend wrapper, scheduler, webhooks +tests/ # pytest test suite; integration tests and fixtures live here dev/ # helper scripts (e.g. per-file coverage checker) docs/ # project outline and design docs Makefile # all build/lint/test commands @@ -41,6 +41,7 @@ Makefile # all build/lint/test commands | `make isort` | Check import ordering (--check-only --diff). | | `make format` | Apply isort formatting in-place. | | `make combined-check` | Run typecheck, lint, flake8, isort, check-max-lines, and test in one shot. | +| `make simulate-ci` | Run the GitHub Actions workflow locally with `act` (requires ACT_* env settings). | | `make build` | Build Docker image via docker compose. | | `make start` | Start the service (depends on build). | | `make stop` | Stop the service. | @@ -48,7 +49,7 @@ Makefile # all build/lint/test commands ## Fixtures And Documentation -- Tests live in `tests/`; fixtures in `tests/fixtures/`. +- Tests live in `tests/`; end-to-end mock-backed integration tests live in `tests/integration/`; fixtures live in `tests/fixtures/`. - Use `make test` (not raw pytest) to ensure coverage gates are enforced. - Update `README.md` with project description, installation instructions, usage, and other relevant information. - Update this file (`AGENTS.md`) with useful information that may help future agents. diff --git a/Makefile b/Makefile index 5c07861..ff47e80 100644 --- a/Makefile +++ b/Makefile @@ -9,6 +9,10 @@ MYPY_PATHS ?= acme_api tests MAX_FILE_LINES ?= 500 MAX_FILE_LINES_PATHS ?= acme_api tests +ACT_VERSION ?= 0.2.89 +ACT_PLATFORM ?= Linux_x86_64 +ACT_IMAGE ?= ghcr.io/catthehacker/ubuntu:full-24.04 + ifneq (,$(wildcard $(ROOT_PATH)/.env)) include $(ROOT_PATH)/.env export diff --git a/README.md b/README.md index 32b022d..b1eadb0 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,14 @@ Lightweight, self-hosted REST service for managing ACME certificates through a modern API while delegating ACME protocol work to `acme.sh`. +## Warning + +This project is grossly experimental. Do not use it for production, important +certificates, or anything you are not prepared to delete and rebuild. + ## Status -Prototype v1 implementation. The core API, SQLite state, API key auth, `acme.sh` backend wrapper, atomic certificate deployment, renewal scheduler, lifecycle webhooks, health/readiness probes, and Docker packaging are implemented. End-to-end staging/Pebble integration tests are still planned. +Prototype v1 implementation. The core API, SQLite state, API key auth, `acme.sh` backend wrapper, atomic certificate deployment, renewal scheduler, lifecycle webhooks, health/readiness probes, Docker packaging, mock-backed end-to-end integration tests, and GitHub Actions CI are implemented. Real staging/Pebble ACME tests remain optional and credential-gated. ## Quick Start @@ -42,11 +47,16 @@ Useful targets: | `make typecheck` | Run strict mypy | | `make lint` | Run flake8 and pylint | | `make isort` | Check import ordering | +| `make check-max-lines` | Enforce file size limits | +| `make combined-check` | Run the full local release gate | +| `make simulate-ci` | Run the GitHub Actions workflow locally with `act` | | `make build` | Build the Docker image | | `make start` | Start the Docker compose service | | `make stop` | Stop the Docker compose service | | `make logs` | Follow container logs | +`make simulate-ci` requires `act` settings in the shell or `.env`: `ACT_VERSION`, `ACT_PLATFORM`, and `ACT_IMAGE`. + ## Configuration Configuration is YAML. By default the app loads `./config.yaml`; set `ACME_API_CONFIG=/path/to/config.yaml` to override it. See `config.example.yaml` for a complete reference. @@ -136,4 +146,3 @@ The public API is independent of the ACME backend. v1 supports DNS-01 through `a - web UI - high availability or clustering - implementing the ACME protocol directly - diff --git a/config.example.yaml b/config.example.yaml index fb934cb..db86938 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -12,6 +12,7 @@ database: deployment: directory: /certificates + permissions_cert: 420 # 0o644 in decimal permissions_key: 384 # 0o600 in decimal acme: diff --git a/docs/future.md b/docs/future.md index 2544164..c00475f 100644 --- a/docs/future.md +++ b/docs/future.md @@ -35,6 +35,22 @@ Before implementing metrics, decide: * TLS-ALPN-01 support. * ACME External Account Binding (EAB). +### Pebble / Staging E2E Tests + +The default test suite should stay deterministic and mock-backed, but a separate +real-ACME compatibility suite would be useful for hardening releases. + +Potential shape: + +* Add `tests/e2e/` with `@pytest.mark.e2e` tests excluded from the default gate. +* Add a `make e2e` target that only runs when explicit prerequisites are present. +* Exercise `acme.sh` against Pebble or Let's Encrypt staging with DNS-01 credentials + supplied through secrets or local env files. +* Verify the minimal full path: app/container starts, certificate request is accepted, + ACME issuance succeeds, artifacts deploy, and the certificate becomes `valid`. +* Run in CI only through `workflow_dispatch`, scheduled nightly jobs, or protected + secret-backed environments to avoid flaky pull request gates. + ## Deployment Targets * Remote deployment targets such as SSH, Kubernetes Secrets, S3-compatible object storage, or Vault. diff --git a/docs/plan.md b/docs/plan.md index 31602cb..0a674c3 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -2,7 +2,7 @@ > Derived from `docs/outline.md`. Targets Python 3.14, strict mypy, 80% per-file coverage gate. > Phase 0/1 foundation exists: package skeleton, config loading, structured logging, request IDs, health endpoint, and tests are wired. -> Phases 2-9 now provide the core DB models, backend abstraction, auth, API routes, atomic deployer, renewal scheduler, webhook dispatcher, lifecycle orchestration, and health/readiness probes. +> Phases 2-12.5 now provide the core DB models, backend abstraction, auth, API routes, atomic deployer, renewal scheduler, webhook dispatcher, lifecycle orchestration, health/readiness probes, Docker packaging, OpenAPI/README polish, integration tests, and CI. > Implementation lesson: lifecycle behavior belongs behind an application service boundary so FastAPI routes and APScheduler callbacks stay thin while backend calls, artifact deployment, state changes, scheduling, audit events, and webhooks remain testable together. --- @@ -161,7 +161,7 @@ Accounts and providers are exposed through read-only API endpoints, but are not **Acceptance:** All endpoints respond with correct status codes; input validation via Pydantic schemas; database CRUD wired end-to-end; OpenAPI docs at `/docs` reflect the API. -**Implementation note:** The current Phase 5 route layer is DB-backed and RBAC-protected, but certificate creation and manual renewal do not yet run the ACME backend/deployer. That is now tracked explicitly in Phase 8.5. +**Implementation note:** Phase 5 initially introduced the DB-backed, RBAC-protected route layer. Phase 8.5 later wired certificate creation and manual renewal to the ACME backend, deployer, scheduler, audit events, and webhooks. --- @@ -188,7 +188,7 @@ Accounts and providers are exposed through read-only API endpoints, but are not **Acceptance:** Deployment produces correct file layout; atomic rename guarantees consumers never see partial writes; filesystem permissions are set correctly (`0644` for certs, `0600` for keys). -**Implementation note:** The deployer is implemented as a reusable boundary. It is not yet called from certificate issuance/renewal flows; that wiring belongs in Phase 8.5. +**Implementation note:** The deployer is implemented as a reusable boundary and is called from successful issuance and renewal flows after Phase 8.5 lifecycle wiring. --- @@ -206,7 +206,7 @@ Accounts and providers are exposed through read-only API endpoints, but are not **Acceptance:** Certificates within renewal window are picked up on startup; scheduled jobs execute and trigger backend renewal; failures logged and reflected in certificate status. -**Implementation note:** The scheduler can renew valid certificates through an injected backend and emit renewed/failed webhooks. Deployment after renewal and full lifecycle integration are tracked in Phase 8.5. +**Implementation note:** The scheduler renews valid certificates through an injected backend, deploys renewed artifacts when configured, records renewal attempts, emits renewed/failed webhooks, and reconstructs renewal jobs on startup. --- @@ -223,7 +223,7 @@ Accounts and providers are exposed through read-only API endpoints, but are not **Acceptance:** Webhooks fire on all lifecycle events; payload matches spec; HMAC signature verifiable by consumer; failed deliveries retried and logged. -**Implementation note:** The webhook dispatcher, HMAC signing, retry handling, DB-backed subscriptions, and failed-delivery audit events are implemented. Not every lifecycle event is emitted by route/service flows yet; that wiring belongs in Phase 8.5. +**Implementation note:** The webhook dispatcher, HMAC signing, retry handling, DB-backed subscriptions, and failed-delivery audit events are implemented. Phase 8.5 wired lifecycle events from route/service and scheduler flows. --- From 67aa79f09a13af3d0987bcf670d305f1dc1401a2 Mon Sep 17 00:00:00 2001 From: Streaky Date: Fri, 3 Jul 2026 03:05:24 +0100 Subject: [PATCH 20/26] fix ci issue --- AGENTS.md | 1 + Makefile | 6 ++- README.md | 1 + scripts/check_forbidden_imports.py | 66 ++++++++++++++++++++++++++++++ tests/test_middleware.py | 2 +- 5 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 scripts/check_forbidden_imports.py diff --git a/AGENTS.md b/AGENTS.md index 84881a5..76ce824 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,6 +40,7 @@ Makefile # all build/lint/test commands | `make lint` | Run flake8 + pylint (fail-under 10.0). | | `make isort` | Check import ordering (--check-only --diff). | | `make format` | Apply isort formatting in-place. | +| `make check-forbidden-imports` | Reject imports of intentionally unsupported packages. | | `make combined-check` | Run typecheck, lint, flake8, isort, check-max-lines, and test in one shot. | | `make simulate-ci` | Run the GitHub Actions workflow locally with `act` (requires ACT_* env settings). | | `make build` | Build Docker image via docker compose. | diff --git a/Makefile b/Makefile index ff47e80..9e3214c 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: venv pip-cache-dir deps dev start build stop logs test typecheck lint flake8 format isort isort-fix check-max-lines combined-check install-act simulate-ci deps-update +.PHONY: venv pip-cache-dir deps dev start build stop logs test typecheck lint flake8 format isort isort-fix check-max-lines check-forbidden-imports combined-check install-act simulate-ci deps-update .ONESHELL: export ROOT_PATH=$(abspath $(dir $(lastword $(MAKEFILE_LIST)))) @@ -56,6 +56,7 @@ logs: test: dev set -e + .venv/bin/python3 scripts/check_forbidden_imports.py ${PY_PATHS} .venv/bin/pytest -vvv --tb=short --color=yes ${PYTEST_COV} --cov-report=xml:coverage-data/coverage.xml --cov-report=html:coverage-data/htmlcov --cov-report=term --cov-report=json:coverage-data/coverage.json ${TEST} ifeq ($(origin TEST), command line) @echo "Skipping per-file coverage gate for scoped TEST=${TEST}" @@ -78,6 +79,9 @@ isort: dev check-max-lines: dev .venv/bin/python3 scripts/check_max_lines.py --max-lines "${MAX_FILE_LINES}" ${MAX_FILE_LINES_PATHS} +check-forbidden-imports: dev + .venv/bin/python3 scripts/check_forbidden_imports.py ${PY_PATHS} + isort-fix: dev .venv/bin/isort ${PY_PATHS} --interactive diff --git a/README.md b/README.md index b1eadb0..5a9eee9 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ Useful targets: | `make lint` | Run flake8 and pylint | | `make isort` | Check import ordering | | `make check-max-lines` | Enforce file size limits | +| `make check-forbidden-imports` | Reject imports of intentionally unsupported packages | | `make combined-check` | Run the full local release gate | | `make simulate-ci` | Run the GitHub Actions workflow locally with `act` | | `make build` | Build the Docker image | diff --git a/scripts/check_forbidden_imports.py b/scripts/check_forbidden_imports.py new file mode 100644 index 0000000..e0e51e9 --- /dev/null +++ b/scripts/check_forbidden_imports.py @@ -0,0 +1,66 @@ +"""Fail when source files import modules that are intentionally unsupported.""" + +from __future__ import annotations + +import argparse +import ast +from pathlib import Path + +FORBIDDEN_IMPORTS = { + "httpx": "Use httpx2; httpx is not a project dependency.", +} + + +def main() -> None: + """Check Python files under the requested paths for forbidden imports.""" + parser = argparse.ArgumentParser() + parser.add_argument("paths", nargs="+", help="Files or directories to scan.") + args = parser.parse_args() + + failures: list[str] = [] + for path_arg in args.paths: + path = Path(path_arg) + for source_path in _python_files(path): + failures.extend(_check_file(source_path)) + + if failures: + raise SystemExit("\n".join(failures)) + + +def _python_files(path: Path) -> list[Path]: + """Return Python source files under a file or directory path.""" + if path.is_file() and path.suffix == ".py": + return [path] + if path.is_dir(): + return sorted(path.rglob("*.py")) + return [] + + +def _check_file(path: Path) -> list[str]: + """Return formatted forbidden import failures for one source file.""" + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + failures: list[str] = [] + + for node in ast.walk(tree): + for module_name in _imported_module_names(node): + root_name = module_name.split(".", maxsplit=1)[0] + reason = FORBIDDEN_IMPORTS.get(root_name) + if reason is not None: + failures.append( + f"{path}:{node.lineno}: forbidden import '{root_name}': {reason}" + ) + + return failures + + +def _imported_module_names(node: ast.AST) -> list[str]: + """Return imported module names for import AST nodes.""" + if isinstance(node, ast.Import): + return [alias.name for alias in node.names] + if isinstance(node, ast.ImportFrom) and node.module is not None: + return [node.module] + return [] + + +if __name__ == "__main__": + main() diff --git a/tests/test_middleware.py b/tests/test_middleware.py index 44ae5cd..fd82029 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -7,7 +7,7 @@ import pytest from fastapi import FastAPI, Request -from httpx import ASGITransport, AsyncClient +from httpx2 import ASGITransport, AsyncClient from acme_api.config import AppSettings, DatabaseConfig, DeploymentConfig from acme_api.logging import request_id as _request_id_ctxvar From 30645f96bbafa52ada0a32d986524855122a1798 Mon Sep 17 00:00:00 2001 From: Streaky Date: Fri, 3 Jul 2026 22:13:22 +0100 Subject: [PATCH 21/26] fix copilot notes where appropriate --- Dockerfile | 23 +++- acme_api/auth/bootstrap.py | 6 +- acme_api/auth/hash.py | 13 +++ acme_api/auth/rbac.py | 18 ++-- acme_api/config.py | 3 +- acme_api/db.py | 18 ++++ acme_api/main.py | 6 +- acme_api/middleware.py | 1 + acme_api/models/api_key.py | 7 ++ acme_api/schemas/event.py | 2 +- .../cb615e9b9c58_add_api_key_lookup_hash.py | 38 +++++++ docker/entrypoint.sh | 13 +-- pyproject.toml | 2 +- requirements-dev.txt | 13 ++- requirements.txt | 7 ++ tests/test_auth_phase4.py | 102 ++++++++++++++++++ tests/test_event_schemas.py | 26 +++++ tests/test_middleware.py | 23 ++++ 18 files changed, 286 insertions(+), 35 deletions(-) create mode 100644 alembic/versions/cb615e9b9c58_add_api_key_lookup_hash.py create mode 100644 tests/test_event_schemas.py diff --git a/Dockerfile b/Dockerfile index d457df2..2c72957 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,9 +17,14 @@ RUN /opt/venv/bin/pip install --no-deps . FROM python:3.14-slim AS runner +ARG ACME_SH_VERSION=3.1.3 +ARG ACME_SH_SHA256=efd12b265252f8875269960b6b31830731ccce2b3e6ff8e7ecfbee21fde35ab4 + ENV ACME_API_CONFIG=/config/config.yaml \ ACME_API_HOST=0.0.0.0 \ ACME_API_PORT=8080 \ + ACME_SH_HOME=/acmesh \ + ACME_SH_PATH=/usr/local/bin/acme.sh \ PATH="/opt/venv/bin:/home/acmeapi/.local/bin:${PATH}" \ PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 @@ -28,8 +33,18 @@ RUN apt-get update \ && apt-get install -y --no-install-recommends ca-certificates curl openssl socat \ && rm -rf /var/lib/apt/lists/* \ && useradd --create-home --home-dir /home/acmeapi --shell /usr/sbin/nologin acmeapi \ - && mkdir -p /config /data /certificates /acmesh /home/acmeapi/.local/bin \ - && chown -R acmeapi:acmeapi /config /data /certificates /acmesh /home/acmeapi + && mkdir -p /config /data /certificates /acmesh /opt/acme.sh \ + && chown -R acmeapi:acmeapi /config /data /certificates /acmesh /home/acmeapi \ + && tmp_dir="$(mktemp -d)" \ + && archive="$tmp_dir/acme.sh.tar.gz" \ + && curl -fsSL "https://github.com/acmesh-official/acme.sh/archive/refs/tags/${ACME_SH_VERSION}.tar.gz" -o "$archive" \ + && echo "${ACME_SH_SHA256} $archive" | sha256sum -c - \ + && tar -xzf "$archive" -C "$tmp_dir" --strip-components=1 \ + && cd "$tmp_dir" \ + && HOME=/home/acmeapi sh ./acme.sh --install --nocron --home /opt/acme.sh \ + && ln -sf /opt/acme.sh/acme.sh /usr/local/bin/acme.sh \ + && rm -rf "$tmp_dir" \ + && chown -R acmeapi:acmeapi /acmesh /home/acmeapi /opt/acme.sh COPY --from=builder /opt/venv /opt/venv COPY docker/entrypoint.sh /usr/local/bin/acme-api-entrypoint @@ -39,6 +54,9 @@ RUN chmod +x /usr/local/bin/acme-api-entrypoint USER acmeapi WORKDIR /app +COPY --chown=acmeapi:acmeapi alembic.ini ./ +COPY --chown=acmeapi:acmeapi alembic ./alembic + EXPOSE 8080 VOLUME ["/config", "/data", "/certificates", "/acmesh"] @@ -47,4 +65,3 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ ENTRYPOINT ["acme-api-entrypoint"] CMD ["acme-api"] - diff --git a/acme_api/auth/bootstrap.py b/acme_api/auth/bootstrap.py index b1368eb..c7131f7 100644 --- a/acme_api/auth/bootstrap.py +++ b/acme_api/auth/bootstrap.py @@ -5,7 +5,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from acme_api.auth.hash import hash_api_key +from acme_api.auth.hash import api_key_lookup_hash, hash_api_key from acme_api.config import AppSettings from acme_api.models.api_key import APIKey, APIKeyRole @@ -43,12 +43,16 @@ async def seed_initial_keys(db: AsyncSession, settings: AppSettings) -> list[API ) ) if existing is not None: + if existing.key_lookup_hash is None: + existing.key_lookup_hash = api_key_lookup_hash(raw_key) + await db.commit() continue hashed = hash_api_key(raw_key) key_obj = APIKey( name=f"bootstrap-{role.value}", hashed_key=hashed, + key_lookup_hash=api_key_lookup_hash(raw_key), role=role, is_active=True, ) diff --git a/acme_api/auth/hash.py b/acme_api/auth/hash.py index 24d37fe..f3c27b5 100644 --- a/acme_api/auth/hash.py +++ b/acme_api/auth/hash.py @@ -5,6 +5,7 @@ import uuid as _uuid from dataclasses import dataclass from datetime import datetime +from hashlib import sha256 from typing import cast from passlib.hash import pbkdf2_sha512 as _pbkdf2_sha512 # type: ignore[import-untyped] @@ -14,6 +15,18 @@ _HASH_ROUNDS = 200_000 +def api_key_lookup_hash(raw_key: str) -> str: + """Return a deterministic lookup digest for API key database filtering. + + The digest is not used as the verifier; successful authentication still + requires matching the PBKDF2 hash. Its purpose is to avoid checking every + active PBKDF2 hash on each request. + """ + if not raw_key: + raise ValueError("API key must not be empty.") + return sha256(raw_key.encode("utf-8")).hexdigest() + + def hash_api_key(raw_key: str) -> str: """Return a PBKDF2-SHA512 hash of the raw API key material. diff --git a/acme_api/auth/rbac.py b/acme_api/auth/rbac.py index fdc25de..9e963a8 100644 --- a/acme_api/auth/rbac.py +++ b/acme_api/auth/rbac.py @@ -8,7 +8,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from acme_api.auth.hash import AuthenticatedUser, verify_api_key +from acme_api.auth.hash import AuthenticatedUser, api_key_lookup_hash, verify_api_key from acme_api.db import get_db_session from acme_api.models.api_key import APIKey, APIKeyRole @@ -77,16 +77,14 @@ async def _authenticate(request: Request, db_session: AsyncSession) -> Authentic headers={"WWW-Authenticate": "Bearer"}, ) - statement = select(APIKey).where(APIKey.is_active.is_(True)) - result = await db_session.execute(statement) - api_key = next( - ( - candidate - for candidate in result.scalars() - if verify_api_key(raw_token, candidate.hashed_key) - ), - None, + lookup_hash = api_key_lookup_hash(raw_token) + statement = select(APIKey).where( + APIKey.is_active.is_(True), + APIKey.key_lookup_hash == lookup_hash, ) + result = await db_session.execute(statement) + candidate = result.scalar_one_or_none() + api_key = candidate if candidate and verify_api_key(raw_token, candidate.hashed_key) else None if not api_key: raise HTTPException( diff --git a/acme_api/config.py b/acme_api/config.py index 014b5be..cdab5e9 100644 --- a/acme_api/config.py +++ b/acme_api/config.py @@ -166,8 +166,7 @@ def check(self) -> None: if len(account_names) != len(set(account_names)): errors.append("acme_accounts contains duplicate 'name' values") - # DNS provider env var files should exist (warn-only at startup; - # hard-fail when first used). This is informational. + # DNS provider env var files must exist before startup succeeds. for provider in self.dns_providers: if not provider.env_vars_file_path.exists(): errors.append( diff --git a/acme_api/db.py b/acme_api/db.py index 7a3a51c..66b14e6 100644 --- a/acme_api/db.py +++ b/acme_api/db.py @@ -4,6 +4,7 @@ import contextlib import typing +from pathlib import Path from sqlalchemy import event as sa_event from sqlalchemy.ext.asyncio import ( @@ -104,3 +105,20 @@ async def init_db(engine: AsyncEngine) -> None: """ async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) + + +def run_migrations(settings: AppSettings) -> None: + """Run Alembic migrations to the latest revision.""" + from alembic import command + from alembic.config import Config + + source_root = Path(__file__).resolve().parent.parent + project_root = next( + candidate + for candidate in (Path.cwd(), source_root) + if (candidate / "alembic.ini").exists() and (candidate / "alembic").is_dir() + ) + config = Config(str(project_root / "alembic.ini")) + config.set_main_option("script_location", str(project_root / "alembic")) + config.set_main_option("sqlalchemy.url", settings.database.url) + command.upgrade(config, "head") diff --git a/acme_api/main.py b/acme_api/main.py index 9cb90db..150224b 100644 --- a/acme_api/main.py +++ b/acme_api/main.py @@ -21,7 +21,7 @@ from acme_api.auth.bootstrap import seed_initial_keys from acme_api.backend.acmesh_backend import AcmeShBackend, _AcmeShBackendConfig from acme_api.config import AppSettings, load_config, prepare_runtime_paths -from acme_api.db import get_db, get_session_factory, init_db, init_engine +from acme_api.db import get_db, get_session_factory, init_engine, run_migrations from acme_api.logging import setup_logging from acme_api.middleware import RequestIdMiddleware from acme_api.readiness import readiness_status @@ -57,9 +57,11 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]: settings.deployment.directory, ) + # Apply schema migrations before opening async application sessions. + run_migrations(settings) + # Phase 4: initialize engine (sets up session factory) and seed API keys. engine = init_engine(settings) - await init_db(engine) async with get_db() as session: created_keys = await seed_initial_keys(session, settings) for key in created_keys: diff --git a/acme_api/middleware.py b/acme_api/middleware.py index da12cad..e2659bd 100644 --- a/acme_api/middleware.py +++ b/acme_api/middleware.py @@ -53,6 +53,7 @@ async def wrapped_send(message: dict[str, Any]) -> None: break else: hdrs.append((b"x-request-id", rid.encode())) + message["headers"] = hdrs headers_sent = True await send(message) diff --git a/acme_api/models/api_key.py b/acme_api/models/api_key.py index ad862de..d926683 100644 --- a/acme_api/models/api_key.py +++ b/acme_api/models/api_key.py @@ -51,6 +51,13 @@ class APIKey(Base, TimestampMixin): nullable=False, doc="PBKDF2 hash of the raw API key material.", ) + key_lookup_hash: Mapped[str | None] = mapped_column( + String(64), + unique=True, + nullable=True, + index=True, + doc="SHA-256 digest used to find the candidate key before PBKDF2 verification.", + ) role: Mapped[APIKeyRole] = mapped_column( Enum(APIKeyRole), nullable=False, diff --git a/acme_api/schemas/event.py b/acme_api/schemas/event.py index 7fb6d2b..48dbc67 100644 --- a/acme_api/schemas/event.py +++ b/acme_api/schemas/event.py @@ -40,4 +40,4 @@ class EventRead(BaseModel): timestamp: datetime event_type: str certificate_id: uuid.UUID | None = None - details: dict[str, Any] = {} + details: dict[str, Any] = Field(default_factory=dict) diff --git a/alembic/versions/cb615e9b9c58_add_api_key_lookup_hash.py b/alembic/versions/cb615e9b9c58_add_api_key_lookup_hash.py new file mode 100644 index 0000000..73c3b1c --- /dev/null +++ b/alembic/versions/cb615e9b9c58_add_api_key_lookup_hash.py @@ -0,0 +1,38 @@ +"""add_api_key_lookup_hash + +Revision ID: cb615e9b9c58 +Revises: 8760d3a7fed0 +Create Date: 2026-07-03 00:00:00.000000 + +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + + +# revision identifiers, used by Alembic. +revision: str = "cb615e9b9c58" +down_revision: Union[str, Sequence[str], None] = "8760d3a7fed0" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + with op.batch_alter_table("api_keys", schema=None) as batch_op: + batch_op.add_column( + sa.Column("key_lookup_hash", sa.String(length=64), nullable=True) + ) + batch_op.create_index( + batch_op.f("ix_api_keys_key_lookup_hash"), + ["key_lookup_hash"], + unique=True, + ) + + +def downgrade() -> None: + """Downgrade schema.""" + with op.batch_alter_table("api_keys", schema=None) as batch_op: + batch_op.drop_index(batch_op.f("ix_api_keys_key_lookup_hash")) + batch_op.drop_column("key_lookup_hash") diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index dd96c60..55ab064 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -1,19 +1,8 @@ #!/bin/sh set -eu -ACME_SH_PATH="${ACME_SH_PATH:-/home/acmeapi/.local/bin/acme.sh}" -ACME_SH_HOME="${ACME_SH_HOME:-/home/acmeapi/.acme.sh}" +ACME_SH_PATH="${ACME_SH_PATH:-/usr/local/bin/acme.sh}" mkdir -p /config /data /certificates /acmesh "$(dirname "$ACME_SH_PATH")" -if [ ! -x "$ACME_SH_PATH" ]; then - tmp_dir="$(mktemp -d)" - trap 'rm -rf "$tmp_dir"' EXIT - curl -fsSL https://github.com/acmesh-official/acme.sh/archive/master.tar.gz \ - | tar -xz -C "$tmp_dir" --strip-components=1 - cd "$tmp_dir" - HOME=/home/acmeapi sh ./acme.sh --install --nocron --home "$ACME_SH_HOME" - ln -sf "$ACME_SH_HOME/acme.sh" "$ACME_SH_PATH" -fi - exec "$@" diff --git a/pyproject.toml b/pyproject.toml index 420ee2a..03b7ef8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ classifiers = [ "Topic :: Security :: Cryptography", ] dependencies = [ + "alembic", "PyYAML", "fastapi", "uvicorn[standard]", @@ -49,7 +50,6 @@ dev = [ "pytest-cov", "pylint", "types-PyYAML", - "alembic", ] [project.scripts] diff --git a/requirements-dev.txt b/requirements-dev.txt index a7084d0..c71bf7d 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -13,7 +13,9 @@ aiosqlite==0.22.1 # -c requirements.txt # acme.api (pyproject.toml) alembic==1.18.5 - # via acme.api (pyproject.toml) + # via + # -c requirements.txt + # acme.api (pyproject.toml) annotated-doc==0.0.4 # via # -c requirements.txt @@ -92,9 +94,13 @@ isort==8.0.1 librt==0.12.0 # via mypy mako==1.3.12 - # via alembic + # via + # -c requirements.txt + # alembic markupsafe==3.0.3 - # via mako + # via + # -c requirements.txt + # mako mccabe==0.7.0 # via # flake8 @@ -186,6 +192,7 @@ typing-extensions==4.15.0 # via # -c requirements.txt # alembic + # alembic # fastapi # mypy # pydantic diff --git a/requirements.txt b/requirements.txt index 37d1b02..6c15aab 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,6 +8,8 @@ aiofiles==24.1.0 # via acme.api (pyproject.toml) aiosqlite==0.22.1 # via acme.api (pyproject.toml) +alembic==1.18.5 + # via acme.api (pyproject.toml) annotated-doc==0.0.4 # via fastapi annotated-types==0.7.0 @@ -41,6 +43,10 @@ idna==3.18 # via # anyio # httpx2 +mako==1.3.12 + # via alembic +markupsafe==3.0.3 + # via mako passlib==1.7.4 # via acme.api (pyproject.toml) pydantic==2.13.4 @@ -69,6 +75,7 @@ truststore==0.10.4 # httpx2 typing-extensions==4.15.0 # via + # alembic # fastapi # pydantic # pydantic-core diff --git a/tests/test_auth_phase4.py b/tests/test_auth_phase4.py index 6dfd130..2524769 100644 --- a/tests/test_auth_phase4.py +++ b/tests/test_auth_phase4.py @@ -7,14 +7,18 @@ import pytest from fastapi.testclient import TestClient +from sqlalchemy import select +from acme_api.auth.bootstrap import seed_initial_keys from acme_api.auth.hash import ( AuthenticatedUser, AuthenticationError, + api_key_lookup_hash, hash_api_key, verify_api_key, ) from acme_api.config import AcmeConfig, AppSettings, DatabaseConfig, DeploymentConfig +from acme_api.db import get_session_factory, init_db, init_engine from acme_api.main import create_app from acme_api.models.api_key import APIKey, APIKeyRole @@ -57,6 +61,13 @@ def test_hash_empty_input_raises(self) -> None: with pytest.raises(ValueError, match="must be at least 8 characters"): hash_api_key("") + def test_api_key_lookup_hash_is_deterministic(self) -> None: + """api_key_lookup_hash returns a stable SHA-256 hex digest.""" + digest = api_key_lookup_hash("admin-key-12345") + + assert digest == api_key_lookup_hash("admin-key-12345") + assert len(digest) == 64 + class TestAuthenticatedUser: def test_authenticated_user_creation(self, sample_keys: dict[str, str]) -> None: @@ -106,6 +117,62 @@ def test_readonly_role_valid(self, sample_keys: dict[str, str]) -> None: assert verify_api_key(raw, hashed) is True +class TestBootstrapKeys: + @pytest.mark.anyio + async def test_seed_initial_keys_rejects_unknown_role(self, tmp_path: Path) -> None: + """Bootstrap config only accepts known API key roles.""" + settings = AppSettings( + database=DatabaseConfig(url=f"sqlite+aiosqlite:///{tmp_path}/test.db"), + deployment=DeploymentConfig(directory=tmp_path / "certs"), + acme=AcmeConfig(home_dir=tmp_path / "acmesh"), + api_keys={"owner": "owner-key-12345"}, + ) + engine = init_engine(settings) + try: + await init_db(engine) + async with get_session_factory()() as session: + with pytest.raises(ValueError, match="Invalid bootstrap key role"): + await seed_initial_keys(session, settings) + finally: + await engine.dispose() + + @pytest.mark.anyio + async def test_seed_initial_keys_backfills_lookup_hash( + self, tmp_path: Path + ) -> None: + """Existing bootstrap keys get a lookup hash when raw config is present.""" + settings = AppSettings( + database=DatabaseConfig(url=f"sqlite+aiosqlite:///{tmp_path}/test.db"), + deployment=DeploymentConfig(directory=tmp_path / "certs"), + acme=AcmeConfig(home_dir=tmp_path / "acmesh"), + api_keys={"readonly": "readonly-key-12345"}, + ) + engine = init_engine(settings) + try: + await init_db(engine) + async with get_session_factory()() as session: + session.add( + APIKey( + name="bootstrap-readonly", + hashed_key=hash_api_key("readonly-key-12345"), + role=APIKeyRole.READONLY, + is_active=True, + ) + ) + await session.commit() + + created = await seed_initial_keys(session, settings) + row = await session.scalar( + select(APIKey).where(APIKey.name == "bootstrap-readonly") + ) + + assert created == [] + assert row is not None + assert row.key_lookup_hash == api_key_lookup_hash("readonly-key-12345") + finally: + await engine.dispose() + + class TestAuthenticationErrors: def test_authentication_error_has_status_code(self) -> None: """AuthenticationError includes HTTP status code.""" @@ -157,3 +224,38 @@ def test_route_auth_matrix(self, tmp_path: Path) -> None: "dns_provider_ref": "cf", }, ).status_code == 202 + + def test_auth_verifies_only_lookup_candidate( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Auth filters by lookup digest before running PBKDF2 verification.""" + settings = AppSettings( + database=DatabaseConfig(url=f"sqlite+aiosqlite:///{tmp_path}/test.db"), + deployment=DeploymentConfig(directory=tmp_path / "certs"), + acme=AcmeConfig(home_dir=tmp_path / "acmesh"), + api_keys={ + "admin": "admin-key-12345", + "operator": "operator-key-12345", + "readonly": "readonly-key-12345", + }, + ) + app = create_app(settings=settings) + calls = 0 + + def _counting_verify(candidate: str, stored_hash: str) -> bool: + nonlocal calls + calls += 1 + return verify_api_key(candidate, stored_hash) + + monkeypatch.setattr("acme_api.auth.rbac.verify_api_key", _counting_verify) + + with TestClient(app) as client: + resp = client.get( + "/v1/certificates", + headers={"Authorization": "Bearer readonly-key-12345"}, + ) + + assert resp.status_code == 200 + assert calls == 1 diff --git a/tests/test_event_schemas.py b/tests/test_event_schemas.py new file mode 100644 index 0000000..cd852ee --- /dev/null +++ b/tests/test_event_schemas.py @@ -0,0 +1,26 @@ +"""Tests for event schemas.""" + +from __future__ import annotations + +import uuid +from datetime import datetime, timezone + +from acme_api.schemas.event import EventRead + + +def test_event_read_details_default_is_per_instance() -> None: + """EventRead instances do not share the default details mapping.""" + first = EventRead( + id=uuid.uuid4(), + timestamp=datetime.now(timezone.utc), + event_type="certificate.created", + ) + second = EventRead( + id=uuid.uuid4(), + timestamp=datetime.now(timezone.utc), + event_type="certificate.renewed", + ) + + first.details["changed"] = True + + assert second.details == {} diff --git a/tests/test_middleware.py b/tests/test_middleware.py index fd82029..9bbaa9a 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -132,6 +132,29 @@ def _send(_message: dict[str, Any]) -> None: assert _request_id_ctxvar.get() is None +@pytest.mark.anyio +async def test_middleware_adds_headers_key_when_missing() -> None: + """Middleware writes headers back when response.start has no headers key.""" + from acme_api.middleware import RequestIdMiddleware + + messages: list[dict[str, Any]] = [] + + async def _app( + _scope: dict[str, Any], + _receive: Callable[..., Any], + send: Callable[[dict[str, Any]], Any], + ) -> None: # noqa: ANN401 + await send({"type": "http.response.start", "status": 204}) + + async def _send(message: dict[str, Any]) -> None: + messages.append(message) + + middleware = RequestIdMiddleware(app=_app) + await middleware({"type": "http", "path": "/ok"}, lambda: None, _send) + + assert messages[0]["headers"][0][0] == b"x-request-id" + + @pytest.mark.anyio async def test_middleware_skips_non_http_scope() -> None: """Middleware passes through non-HTTP scopes without setting request ID.""" From 45a8db4463f3ed98f06b41925aef77ce4c53beab Mon Sep 17 00:00:00 2001 From: Streaky Date: Fri, 3 Jul 2026 22:21:13 +0100 Subject: [PATCH 22/26] some ci warning cleanup. --- alembic/env.py | 21 ++++++++++++--------- requirements-dev.txt | 1 - requirements.txt | 4 +++- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/alembic/env.py b/alembic/env.py index 9fa73db..4edfaf2 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -111,15 +111,18 @@ def run_migrations_online() -> None: # noqa: PLR0915 poolclass=pool.NullPool, ) - with connectable.connect() as connection: - context.configure( - connection=connection, - target_metadata=target_metadata, - render_as_batch=True, - ) - - with context.begin_transaction(): - context.run_migrations() + try: + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + render_as_batch=True, + ) + + with context.begin_transaction(): + context.run_migrations() + finally: + connectable.dispose() if context.is_offline_mode(): diff --git a/requirements-dev.txt b/requirements-dev.txt index c71bf7d..d093bd0 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -192,7 +192,6 @@ typing-extensions==4.15.0 # via # -c requirements.txt # alembic - # alembic # fastapi # mypy # pydantic diff --git a/requirements.txt b/requirements.txt index 6c15aab..367d39a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -66,7 +66,9 @@ pyyaml==6.0.3 # acme.api (pyproject.toml) # uvicorn sqlalchemy==2.0.51 - # via acme.api (pyproject.toml) + # via + # acme.api (pyproject.toml) + # alembic starlette==1.3.1 # via fastapi truststore==0.10.4 From 6a4468490d4cbaecb9c191f8b62efd7bcf5a6d2b Mon Sep 17 00:00:00 2001 From: Streaky Date: Fri, 3 Jul 2026 22:57:43 +0100 Subject: [PATCH 23/26] more copilot notes --- .env | 2 +- docker/config.yaml | 3 +-- tests/test_alembic.py | 1 + 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.env b/.env index 3c3a310..07c1269 100644 --- a/.env +++ b/.env @@ -1 +1 @@ -PYTEST_COV = --cov=acme_api --cov=tests +PYTEST_COV=--cov=acme_api --cov=tests diff --git a/docker/config.yaml b/docker/config.yaml index fc4d698..15558e4 100644 --- a/docker/config.yaml +++ b/docker/config.yaml @@ -12,7 +12,7 @@ deployment: permissions_key: 384 acme: - binary_path: /home/acmeapi/.local/bin/acme.sh + binary_path: /usr/local/bin/acme.sh home_dir: /acmesh renewal: @@ -30,4 +30,3 @@ webhooks: api_keys: {} dns_providers: [] acme_accounts: [] - diff --git a/tests/test_alembic.py b/tests/test_alembic.py index 2a1779f..c6fa4d8 100644 --- a/tests/test_alembic.py +++ b/tests/test_alembic.py @@ -93,6 +93,7 @@ class = StreamHandler expected_tables = { "alembic_version", + "api_keys", "certificates", "events", "renewal_attempts", From ee9cbb69c5c0a8e03fb9dc81d538328efebf991c Mon Sep 17 00:00:00 2001 From: Streaky Date: Fri, 3 Jul 2026 23:02:25 +0100 Subject: [PATCH 24/26] sec hardening --- acme_api/middleware.py | 15 +++++++++++++-- tests/test_middleware.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/acme_api/middleware.py b/acme_api/middleware.py index e2659bd..039a8d5 100644 --- a/acme_api/middleware.py +++ b/acme_api/middleware.py @@ -6,12 +6,15 @@ from __future__ import annotations +import re from typing import Any, Callable, cast from uuid import uuid4 from acme_api.logging import request_id as _request_id_ctxvar ASGIApp = Callable[[Any, Any, Any], Any] +_MAX_REQUEST_ID_LENGTH = 128 +_REQUEST_ID_PATTERN = re.compile(r"^[A-Za-z0-9._-]{1,128}$") class RequestIdMiddleware: @@ -64,11 +67,19 @@ async def wrapped_send(message: dict[str, Any]) -> None: @staticmethod def _get_request_id(scope: dict[str, Any]) -> str: - """Return the incoming request ID header or generate a new ID.""" + """Return a validated request ID header value or generate a new ID. + + Security invariant: client-supplied request IDs must be short and use a + restricted character set so they are safe for response headers and logs. + """ headers = cast(list[tuple[bytes, bytes]], scope.get("headers", [])) for key, value in headers: if key.lower() == b"x-request-id": decoded = value.decode("latin-1").strip() - if decoded: + if ( + decoded + and len(decoded) <= _MAX_REQUEST_ID_LENGTH + and _REQUEST_ID_PATTERN.fullmatch(decoded) + ): return decoded return str(uuid4()) diff --git a/tests/test_middleware.py b/tests/test_middleware.py index 9bbaa9a..b0ef94d 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -74,6 +74,37 @@ async def test_middleware_preserves_incoming_request_id(tmp_path: Path) -> None: assert resp.headers["x-request-id"] == "external-123" +def test_get_request_id_rejects_crlf_injection() -> None: + """CRLF bytes in request ID are rejected and replaced.""" + from acme_api.middleware import RequestIdMiddleware + + scope = { + "headers": [(b"x-request-id", b"legit-id\r\nX-Injected: evil")], + } + + rid = RequestIdMiddleware._get_request_id(scope) + + assert rid != "legit-id\r\nX-Injected: evil" + assert "\r" not in rid + assert "\n" not in rid + + +def test_get_request_id_rejects_oversized_value() -> None: + """Overly long request IDs are rejected and replaced.""" + from acme_api.middleware import RequestIdMiddleware + + oversized = "a" * 129 + scope = { + "headers": [(b"x-request-id", oversized.encode("ascii"))], + } + + rid = RequestIdMiddleware._get_request_id(scope) + + assert rid != oversized + assert len(rid) > 0 + assert len(rid) <= 128 + + @pytest.mark.anyio async def test_middleware_sets_request_state(tmp_path: Path) -> None: """Request ID is available to handlers through request.state.""" From 5fa294c0b3fac44b63450e729aef08a80c8ed780 Mon Sep 17 00:00:00 2001 From: Streaky Date: Fri, 3 Jul 2026 23:31:01 +0100 Subject: [PATCH 25/26] preemptive security audit --- acme_api/backend/acmesh_backend.py | 28 ++++- acme_api/deployer.py | 167 ++++++++++++++++++++--------- acme_api/main.py | 5 + acme_api/scheduler.py | 12 ++- acme_api/schemas/certificate.py | 7 ++ acme_api/services/certificates.py | 21 +++- acme_api/webhooks.py | 38 +++++++ tests/test_backend_commands.py | 13 +++ tests/test_deployer.py | 38 ++++++- tests/test_middleware.py | 50 ++++++--- tests/test_schemas.py | 11 ++ tests/test_webhooks.py | 44 ++++++++ 12 files changed, 361 insertions(+), 73 deletions(-) diff --git a/acme_api/backend/acmesh_backend.py b/acme_api/backend/acmesh_backend.py index 7a0838b..3a93651 100644 --- a/acme_api/backend/acmesh_backend.py +++ b/acme_api/backend/acmesh_backend.py @@ -18,6 +18,21 @@ logger = logging.getLogger(__name__) +_ENV_KEY_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") +_BLOCKED_ENV_KEYS = { + "PATH", + "HOME", + "LD_PRELOAD", + "LD_LIBRARY_PATH", + "PYTHONPATH", + "PYTHONHOME", + "VIRTUAL_ENV", + "IFS", + "ENV", + "BASH_ENV", + "SHELL", +} + _PATH_PATTERNS = { "cert": ( @@ -328,7 +343,18 @@ def _load_env_vars(env_vars_file: pathlib.Path) -> dict[str, str]: line = line[len("export ") :] key, separator, value = line.partition("=") if separator: - result[key.strip()] = shlex.split(value.strip())[0] if value.strip() else "" + key = key.strip() + if not _ENV_KEY_PATTERN.fullmatch(key): + logger.warning("Ignoring invalid env var name from %s: %r", env_vars_file, key) + continue + if key.upper() in _BLOCKED_ENV_KEYS: + logger.warning( + "Ignoring blocked env var name from %s: %s", + env_vars_file, + key, + ) + continue + result[key] = shlex.split(value.strip())[0] if value.strip() else "" return result diff --git a/acme_api/deployer.py b/acme_api/deployer.py index 7666e3d..1de6e58 100644 --- a/acme_api/deployer.py +++ b/acme_api/deployer.py @@ -6,6 +6,7 @@ import json import os import shutil +import stat import tempfile from datetime import datetime, timezone from pathlib import Path @@ -62,6 +63,16 @@ def to_json_dict(self) -> dict[str, Any]: } +@dc.dataclass(frozen=True) +class DeploymentOptions: + """Options that control certificate deployment behavior.""" + + permissions_cert: int = 0o644 + permissions_key: int = 0o600 + issuer: str | None = None + allowed_source_roots: list[Path] | None = None + + class DeploymentError(Exception): """Raised when certificate artifacts cannot be deployed safely.""" @@ -70,18 +81,14 @@ def deploy_issuance_result( result: IssuanceResult, deployment_root: Path, *, - permissions_cert: int = 0o644, - permissions_key: int = 0o600, - issuer: str | None = None, + options: DeploymentOptions | None = None, ) -> DeploymentPaths: """Deploy files from an ACME issuance or renewal result. Args: result: Backend result containing source artifact paths and issued domains. deployment_root: Root directory such as ``/certificates``. - permissions_cert: POSIX mode for public certificate artifacts. - permissions_key: POSIX mode for the private key. - issuer: Optional issuer string to include in metadata. + options: Optional deployment behavior overrides. Returns: Paths to the deployed certificate artifacts. @@ -89,12 +96,13 @@ def deploy_issuance_result( Raises: DeploymentError: If domains are missing, unsafe, or source files are absent. """ + options = options or DeploymentOptions() cert = result.cert metadata = DeploymentMetadata( primary_domain=_primary_domain(result.domains), domains=list(result.domains), expires_at=cert.expires_at, - issuer=issuer, + issuer=options.issuer, source_cert_path=cert.cert_path, source_chain_path=cert.chain_path, source_fullchain_path=cert.fullchain_path, @@ -104,8 +112,9 @@ def deploy_issuance_result( domains=result.domains, deployment_root=deployment_root, metadata=metadata, - permissions_cert=permissions_cert, - permissions_key=permissions_key, + permissions_cert=options.permissions_cert, + permissions_key=options.permissions_key, + allowed_source_roots=options.allowed_source_roots, ) @@ -117,56 +126,30 @@ def deploy_certificate_artifacts( # pylint: disable=too-many-arguments metadata: DeploymentMetadata | None = None, permissions_cert: int = 0o644, permissions_key: int = 0o600, + allowed_source_roots: list[Path] | None = None, ) -> DeploymentPaths: """Atomically deploy certificate files under the primary domain directory.""" primary_domain = _primary_domain(domains) target_dir = deployment_root / _safe_domain_dir_name(primary_domain) target_dir.mkdir(parents=True, exist_ok=True) - source_paths = { - CERT_FILE_NAME: Path(cert.cert_path), - CHAIN_FILE_NAME: Path(cert.chain_path), - FULLCHAIN_FILE_NAME: Path(cert.fullchain_path), - PRIVKEY_FILE_NAME: Path(cert.privkey_path), - } - _validate_source_files(source_paths) - - if metadata is None: - metadata = DeploymentMetadata( - primary_domain=primary_domain, - domains=list(domains), - expires_at=cert.expires_at, - source_cert_path=cert.cert_path, - source_chain_path=cert.chain_path, - source_fullchain_path=cert.fullchain_path, - ) + source_paths = _source_paths(cert) + _validate_source_files(source_paths, allowed_source_roots) + + metadata = metadata or _metadata_for_cert(cert, domains, primary_domain) temp_dir = Path(tempfile.mkdtemp(prefix=".deploy-", dir=target_dir)) try: - for file_name, source_path in source_paths.items(): - mode = permissions_key if file_name == PRIVKEY_FILE_NAME else permissions_cert - _copy_fsync_chmod(source_path, temp_dir / f"{file_name}.tmp", mode) - - metadata_bytes = json.dumps( - metadata.to_json_dict(), - indent=2, - sort_keys=True, - ).encode("utf-8") - _write_fsync_chmod( - temp_dir / f"{METADATA_FILE_NAME}.tmp", - metadata_bytes, - permissions_cert, + _write_temp_artifacts( + temp_dir=temp_dir, + source_paths=source_paths, + metadata=metadata, + permissions_cert=permissions_cert, + permissions_key=permissions_key, ) _fsync_directory(temp_dir) - for file_name in ( - CERT_FILE_NAME, - CHAIN_FILE_NAME, - FULLCHAIN_FILE_NAME, - PRIVKEY_FILE_NAME, - METADATA_FILE_NAME, - ): - os.replace(temp_dir / f"{file_name}.tmp", target_dir / file_name) + _replace_artifacts(temp_dir, target_dir) _fsync_directory(target_dir) except OSError as exc: raise DeploymentError(f"failed to deploy certificate artifacts: {exc}") from exc @@ -183,6 +166,69 @@ def deploy_certificate_artifacts( # pylint: disable=too-many-arguments ) +def _write_temp_artifacts( + *, + temp_dir: Path, + source_paths: dict[str, Path], + metadata: DeploymentMetadata, + permissions_cert: int, + permissions_key: int, +) -> None: + """Write all deployment artifacts into the temporary directory.""" + for file_name, source_path in source_paths.items(): + mode = permissions_key if file_name == PRIVKEY_FILE_NAME else permissions_cert + _copy_fsync_chmod(source_path, temp_dir / f"{file_name}.tmp", mode) + + metadata_bytes = json.dumps( + metadata.to_json_dict(), + indent=2, + sort_keys=True, + ).encode("utf-8") + _write_fsync_chmod( + temp_dir / f"{METADATA_FILE_NAME}.tmp", + metadata_bytes, + permissions_cert, + ) + + +def _replace_artifacts(temp_dir: Path, target_dir: Path) -> None: + """Atomically replace target artifacts with staged temporary files.""" + for file_name in ( + CERT_FILE_NAME, + CHAIN_FILE_NAME, + FULLCHAIN_FILE_NAME, + PRIVKEY_FILE_NAME, + METADATA_FILE_NAME, + ): + os.replace(temp_dir / f"{file_name}.tmp", target_dir / file_name) + + +def _source_paths(cert: CertExpiry) -> dict[str, Path]: + """Return deployment destination names mapped to source artifact paths.""" + return { + CERT_FILE_NAME: Path(cert.cert_path), + CHAIN_FILE_NAME: Path(cert.chain_path), + FULLCHAIN_FILE_NAME: Path(cert.fullchain_path), + PRIVKEY_FILE_NAME: Path(cert.privkey_path), + } + + +def _metadata_for_cert( + cert: CertExpiry, + domains: list[str], + primary_domain: str, +) -> DeploymentMetadata: + """Build default deployment metadata from certificate artifacts.""" + return DeploymentMetadata( + primary_domain=primary_domain, + domains=list(domains), + expires_at=cert.expires_at, + source_cert_path=cert.cert_path, + source_chain_path=cert.chain_path, + source_fullchain_path=cert.fullchain_path, + ) + + def _primary_domain(domains: list[str]) -> str: """Return the SAN primary domain.""" if not domains: @@ -199,8 +245,12 @@ def _safe_domain_dir_name(domain: str) -> str: return domain -def _validate_source_files(source_paths: dict[str, Path]) -> None: - """Ensure all source artifact paths exist and are regular files.""" +def _validate_source_files( + source_paths: dict[str, Path], + allowed_source_roots: list[Path] | None = None, +) -> None: + """Ensure all source artifact paths are safe regular files.""" + normalized_roots = [root.resolve() for root in (allowed_source_roots or [])] missing = [ f"{name}: {path}" for name, path in source_paths.items() @@ -211,6 +261,25 @@ def _validate_source_files(source_paths: dict[str, Path]) -> None: "missing certificate source artifact(s): " + ", ".join(missing) ) + unsafe: list[str] = [] + for name, path in source_paths.items(): + if path.is_symlink(): + unsafe.append(f"{name}: {path} (symlinks are not allowed)") + continue + + mode = path.lstat().st_mode + if not stat.S_ISREG(mode): + unsafe.append(f"{name}: {path} (not a regular file)") + continue + + if normalized_roots: + resolved = path.resolve() + if not any(resolved.is_relative_to(root) for root in normalized_roots): + unsafe.append(f"{name}: {path} (outside allowed source roots)") + + if unsafe: + raise DeploymentError("unsafe certificate source artifact(s): " + ", ".join(unsafe)) + def _copy_fsync_chmod(source: Path, destination: Path, mode: int) -> None: """Copy a source file to destination, flush it, fsync it, and chmod it.""" diff --git a/acme_api/main.py b/acme_api/main.py index 150224b..2aa342e 100644 --- a/acme_api/main.py +++ b/acme_api/main.py @@ -98,6 +98,11 @@ def webhook_dispatcher_factory(session: AsyncSession) -> WebhookDispatcher: root=settings.deployment.directory, permissions_cert=settings.deployment.permissions_cert, permissions_key=settings.deployment.permissions_key, + allowed_source_roots=( + [settings.acme.home_dir] + if isinstance(backend, AcmeShBackend) + else None + ), ), ) app.state.renewal_scheduler = renewal_scheduler diff --git a/acme_api/scheduler.py b/acme_api/scheduler.py index e08cca9..7e627b2 100644 --- a/acme_api/scheduler.py +++ b/acme_api/scheduler.py @@ -20,7 +20,7 @@ from acme_api.backend.dataclasses import IssuanceResult from acme_api.backend.protocol import AcmeBackend from acme_api.config import RenewalConfig -from acme_api.deployer import DeploymentError, deploy_issuance_result +from acme_api.deployer import DeploymentError, DeploymentOptions, deploy_issuance_result from acme_api.models.certificate import Certificate, CertificateStatus from acme_api.models.event import Event from acme_api.models.renewal_attempt import RenewalAttempt @@ -37,6 +37,7 @@ class RenewalDeploymentConfig: root: Path permissions_cert: int = 0o644 permissions_key: int = 0o600 + allowed_source_roots: list[Path] | None = None class RenewalScheduler: @@ -224,9 +225,12 @@ def _deploy_result( deployed = deploy_issuance_result( result, self._deployment.root, - permissions_cert=self._deployment.permissions_cert, - permissions_key=self._deployment.permissions_key, - issuer=issuer, + options=DeploymentOptions( + permissions_cert=self._deployment.permissions_cert, + permissions_key=self._deployment.permissions_key, + issuer=issuer, + allowed_source_roots=self._deployment.allowed_source_roots, + ), ) return deployed.directory diff --git a/acme_api/schemas/certificate.py b/acme_api/schemas/certificate.py index 286c74e..90930f5 100644 --- a/acme_api/schemas/certificate.py +++ b/acme_api/schemas/certificate.py @@ -10,6 +10,8 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator +_MAX_FQDN_LENGTH = 253 + class CertificateStatus(StrEnum): """Lifecycle states a certificate can occupy.""" @@ -54,6 +56,11 @@ def _validate_domains(cls, v: list[str]) -> list[str]: raise ValueError( f"Domain {domain!r} does not match a valid DNS label pattern." ) + normalized = domain[2:] if domain.startswith("*.") else domain + if len(normalized) > _MAX_FQDN_LENGTH: + raise ValueError( + f"Domain {domain!r} exceeds maximum length of {_MAX_FQDN_LENGTH} characters." + ) return v diff --git a/acme_api/services/certificates.py b/acme_api/services/certificates.py index 1b67341..883ef40 100644 --- a/acme_api/services/certificates.py +++ b/acme_api/services/certificates.py @@ -11,10 +11,14 @@ from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker -from acme_api.backend.acmesh_backend import AcmeShError, TransientAcmeShError +from acme_api.backend.acmesh_backend import ( + AcmeShBackend, + AcmeShError, + TransientAcmeShError, +) from acme_api.backend.protocol import AcmeBackend from acme_api.config import AcmeAccountConfig, AppSettings, DnsProviderConfig -from acme_api.deployer import DeploymentError, deploy_issuance_result +from acme_api.deployer import DeploymentError, DeploymentOptions, deploy_issuance_result from acme_api.models.certificate import Certificate, CertificateStatus from acme_api.models.event import Event from acme_api.schemas.certificate import CertificateCreate @@ -126,9 +130,16 @@ async def issue_certificate(self, certificate_id: uuid.UUID) -> None: deployed = deploy_issuance_result( result, self._settings.deployment.directory, - permissions_cert=self._settings.deployment.permissions_cert, - permissions_key=self._settings.deployment.permissions_key, - issuer=certificate.acme_account_ref, + options=DeploymentOptions( + permissions_cert=self._settings.deployment.permissions_cert, + permissions_key=self._settings.deployment.permissions_key, + issuer=certificate.acme_account_ref, + allowed_source_roots=( + [self._settings.acme.home_dir] + if isinstance(self._backend, AcmeShBackend) + else None + ), + ), ) except (AcmeShError, DeploymentError) as exc: await self._mark_failed(session, certificate, exc) diff --git a/acme_api/webhooks.py b/acme_api/webhooks.py index 692e8b0..d4196e1 100644 --- a/acme_api/webhooks.py +++ b/acme_api/webhooks.py @@ -6,10 +6,12 @@ import dataclasses as dc import hashlib import hmac +import ipaddress import json import uuid from datetime import datetime, timezone from typing import Any +from urllib.parse import urlsplit import httpx2 from sqlalchemy import select @@ -62,6 +64,40 @@ class WebhookDeliveryError(Exception): """Raised when a webhook delivery exhausts all retry attempts.""" +def _validate_webhook_url(url: str) -> None: + """Validate webhook destination URL before making network calls.""" + parsed = urlsplit(url) + if parsed.scheme not in {"http", "https"}: + raise WebhookDeliveryError("unsafe webhook url: only http/https are allowed") + if not parsed.hostname: + raise WebhookDeliveryError("unsafe webhook url: missing hostname") + + host = parsed.hostname.lower() + if host == "localhost": + raise WebhookDeliveryError("unsafe webhook url: localhost is not allowed") + + try: + ip = ipaddress.ip_address(host) + except ValueError: + return + + if _is_unsafe_webhook_ip(ip): + raise WebhookDeliveryError("unsafe webhook url: non-public IP targets are not allowed") + + +def _is_unsafe_webhook_ip(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool: + """Return whether an IP address is unsafe for outbound webhooks.""" + unsafe_flags = ( + ip.is_private, + ip.is_loopback, + ip.is_link_local, + ip.is_multicast, + ip.is_reserved, + ip.is_unspecified, + ) + return any(unsafe_flags) + + def encode_payload(payload: WebhookPayload) -> bytes: """Serialize a webhook payload using stable JSON formatting.""" return json.dumps( @@ -170,6 +206,8 @@ async def _deliver_to_config( if self._client is None: raise RuntimeError("WebhookDispatcher must be used as an async context manager") + _validate_webhook_url(config.url) + body = encode_payload(payload) headers = { "Content-Type": "application/json", diff --git a/tests/test_backend_commands.py b/tests/test_backend_commands.py index b6ac306..e06087c 100644 --- a/tests/test_backend_commands.py +++ b/tests/test_backend_commands.py @@ -229,6 +229,19 @@ def test_load_env_vars_handles_exports_and_quotes(self, tmp_path: pathlib.Path) "CF_Account_ID": "abc123", } + def test_load_env_vars_ignores_blocked_and_invalid_names( + self, tmp_path: pathlib.Path + ) -> None: + env_file = tmp_path / "dns.env" + env_file.write_text( + "PATH=/tmp/evil\nCF_API_TOKEN=token\nINVALID-KEY=value\n", + encoding="utf-8", + ) + + assert _load_env_vars(env_file) == { + "CF_API_TOKEN": "token", + } + class TestErrorClassification: """Verify subprocess failures map to retryable or terminal errors.""" diff --git a/tests/test_deployer.py b/tests/test_deployer.py index bc2d412..0e9d27d 100644 --- a/tests/test_deployer.py +++ b/tests/test_deployer.py @@ -13,6 +13,7 @@ from acme_api.backend.dataclasses import CertExpiry, IssuanceResult from acme_api.deployer import ( DeploymentError, + DeploymentOptions, deploy_certificate_artifacts, deploy_issuance_result, ) @@ -52,7 +53,11 @@ def test_deploy_issuance_result_writes_expected_layout(tmp_path: pathlib.Path) - domains=["example.com", "www.example.com"], ) - deployed = deploy_issuance_result(result, tmp_path / "certificates", issuer="test-ca") + deployed = deploy_issuance_result( + result, + tmp_path / "certificates", + options=DeploymentOptions(issuer="test-ca"), + ) assert deployed.directory == tmp_path / "certificates" / "example.com" assert deployed.cert_path.read_bytes() == b"server-cert\n" @@ -110,6 +115,37 @@ def test_missing_source_file_raises(tmp_path: pathlib.Path) -> None: ) +def test_symlink_source_file_raises(tmp_path: pathlib.Path) -> None: + """Deployment rejects symlinked source artifacts.""" + cert = _write_sources(tmp_path) + cert_path = pathlib.Path(cert.cert_path) + target_bytes = cert_path.read_bytes() + cert_path.unlink() + real_cert = cert_path.parent / "real-cert.pem" + real_cert.write_bytes(target_bytes) + cert_path.symlink_to(real_cert) + + with pytest.raises(DeploymentError, match="unsafe certificate source"): + deploy_certificate_artifacts( + cert=cert, + domains=["example.com"], + deployment_root=tmp_path / "certificates", + ) + + +def test_source_outside_allowed_root_raises(tmp_path: pathlib.Path) -> None: + """Deployment rejects source artifacts resolved outside allowed roots.""" + cert = _write_sources(tmp_path) + + with pytest.raises(DeploymentError, match="outside allowed source roots"): + deploy_certificate_artifacts( + cert=cert, + domains=["example.com"], + deployment_root=tmp_path / "certificates", + allowed_source_roots=[tmp_path / "other-root"], + ) + + def test_unsafe_primary_domain_raises(tmp_path: pathlib.Path) -> None: """Primary domain must not escape the deployment root.""" cert = _write_sources(tmp_path) diff --git a/tests/test_middleware.py b/tests/test_middleware.py index b0ef94d..9a02e4f 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -3,7 +3,7 @@ from __future__ import annotations from pathlib import Path -from typing import Any, Callable, Generator +from typing import Any, Callable, Generator, cast import pytest from fastapi import FastAPI, Request @@ -74,31 +74,55 @@ async def test_middleware_preserves_incoming_request_id(tmp_path: Path) -> None: assert resp.headers["x-request-id"] == "external-123" -def test_get_request_id_rejects_crlf_injection() -> None: - """CRLF bytes in request ID are rejected and replaced.""" +async def _request_id_for_raw_header(value: bytes) -> str: + """Return response request ID after passing a raw header through middleware.""" from acme_api.middleware import RequestIdMiddleware + messages: list[dict[str, Any]] = [] + + async def _app( + _scope: dict[str, Any], + _receive: Callable[..., Any], + send: Callable[[dict[str, Any]], Any], + ) -> None: # noqa: ANN401 + await send({"type": "http.response.start", "status": 204}) + + async def _receive() -> dict[str, Any]: + return {"type": "http.request", "body": b"", "more_body": False} + + async def _send(message: dict[str, Any]) -> None: + messages.append(message) + + middleware = RequestIdMiddleware(app=_app) scope = { - "headers": [(b"x-request-id", b"legit-id\r\nX-Injected: evil")], + "type": "http", + "path": "/", + "headers": [(b"x-request-id", value)], } + await middleware(scope, _receive, _send) + + response_headers = cast(dict[bytes, bytes], dict(messages[0]["headers"])) + return response_headers[b"x-request-id"].decode("ascii") - rid = RequestIdMiddleware._get_request_id(scope) - assert rid != "legit-id\r\nX-Injected: evil" +@pytest.mark.anyio +async def test_get_request_id_rejects_crlf_injection() -> None: + """CRLF bytes in request ID are rejected and replaced.""" + bad_request_id = "legit-id\r\nX-Injected: evil" + + rid = await _request_id_for_raw_header(bad_request_id.encode("latin-1")) + + assert rid != bad_request_id assert "\r" not in rid assert "\n" not in rid -def test_get_request_id_rejects_oversized_value() -> None: +@pytest.mark.anyio +async def test_get_request_id_rejects_oversized_value() -> None: """Overly long request IDs are rejected and replaced.""" - from acme_api.middleware import RequestIdMiddleware - oversized = "a" * 129 - scope = { - "headers": [(b"x-request-id", oversized.encode("ascii"))], - } - rid = RequestIdMiddleware._get_request_id(scope) + rid = await _request_id_for_raw_header(oversized.encode("ascii")) assert rid != oversized assert len(rid) > 0 diff --git a/tests/test_schemas.py b/tests/test_schemas.py index ccc4145..a2101eb 100644 --- a/tests/test_schemas.py +++ b/tests/test_schemas.py @@ -85,6 +85,17 @@ def test_domain_validation_accepts_wildcard(self) -> None: ) assert "*.example.com" in cert.domains + def test_domain_validation_rejects_overlong_fqdn(self) -> None: + """Domains longer than RFC max length should fail validation.""" + too_long = ".".join(["a" * 63, "b" * 63, "c" * 63, "d" * 62]) + with pytest.raises(ValueError, match="exceeds maximum length"): + CertificateCreate( + name="my-cert", + domains=[too_long], + acme_account_ref="acme-acc", + dns_provider_ref="dns-prov", + ) + def test_empty_domains_raises(self) -> None: """An empty domains list should raise (min_length=1).""" with pytest.raises(ValueError): diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py index b44f8a1..d5848dd 100644 --- a/tests/test_webhooks.py +++ b/tests/test_webhooks.py @@ -181,3 +181,47 @@ def handler(_request: httpx2.Request) -> httpx2.Response: assert delivered == 0 assert len(events) == 1 assert events[0].details["event"] == "certificate.failed" + + +@pytest.mark.anyio +async def test_dispatch_rejects_unsafe_webhook_targets(db_session: AsyncSession) -> None: + """Unsafe webhook targets are blocked before any network call.""" + certificate = Certificate( + name="unsafe-hook-cert", + domains=["unsafe-hook.example.com"], + acme_account_ref="le", + dns_provider_ref="cf", + status=CertificateStatus.VALID, + ) + db_session.add(certificate) + db_session.add( + WebhookConfig( + url="http://127.0.0.1/internal", + events=["certificate.failed"], + secret="shared-secret", + ) + ) + await db_session.commit() + + attempts = 0 + + def handler(_request: httpx2.Request) -> httpx2.Response: + nonlocal attempts + attempts += 1 + return httpx2.Response(204) + + async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as client: + async with WebhookDispatcher(db_session, client=client) as dispatcher: + delivered = await dispatcher.dispatch_certificate_event( + "certificate.failed", certificate + ) + + events = ( + await db_session.execute( + select(Event).where(Event.event_type == "webhook.delivery_failed") + ) + ).scalars().all() + assert delivered == 0 + assert attempts == 0 + assert len(events) == 1 + assert "unsafe webhook url" in str(events[0].details["error"]) From aae27f030e0b2c90732a53364da3d8d202f46a1e Mon Sep 17 00:00:00 2001 From: Streaky Date: Fri, 3 Jul 2026 23:43:47 +0100 Subject: [PATCH 26/26] deal with copilot notes --- acme_api/webhooks.py | 18 ++++++++- alembic/env.py | 5 ++- tests/integration/test_e2e_lifecycle.py | 5 +++ tests/test_alembic.py | 3 +- tests/test_webhooks.py | 54 +++++++++++++++++++++++++ 5 files changed, 82 insertions(+), 3 deletions(-) diff --git a/acme_api/webhooks.py b/acme_api/webhooks.py index d4196e1..473b9c4 100644 --- a/acme_api/webhooks.py +++ b/acme_api/webhooks.py @@ -8,6 +8,7 @@ import hmac import ipaddress import json +import socket import uuid from datetime import datetime, timezone from typing import Any @@ -78,13 +79,28 @@ def _validate_webhook_url(url: str) -> None: try: ip = ipaddress.ip_address(host) - except ValueError: + except ValueError as exc: + for resolved_ip in _resolve_host_ips(host): + if _is_unsafe_webhook_ip(resolved_ip): + raise WebhookDeliveryError( + "unsafe webhook url: non-public IP targets are not allowed" + ) from exc return if _is_unsafe_webhook_ip(ip): raise WebhookDeliveryError("unsafe webhook url: non-public IP targets are not allowed") +def _resolve_host_ips(host: str) -> set[ipaddress.IPv4Address | ipaddress.IPv6Address]: + """Resolve a hostname to IP addresses for webhook target validation.""" + try: + results = socket.getaddrinfo(host, None, type=socket.SOCK_STREAM) + except socket.gaierror as exc: + raise WebhookDeliveryError("unsafe webhook url: hostname could not be resolved") from exc + + return {ipaddress.ip_address(result[4][0]) for result in results} + + def _is_unsafe_webhook_ip(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool: """Return whether an IP address is unsafe for outbound webhooks.""" unsafe_flags = ( diff --git a/alembic/env.py b/alembic/env.py index 4edfaf2..d547b7f 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -16,7 +16,7 @@ from logging.config import fileConfig from pathlib import Path -from sqlalchemy import engine_from_config, pool +from sqlalchemy import engine_from_config, make_url, pool from alembic import context @@ -53,6 +53,9 @@ def _normalise_url(url: str) -> str: The resulting sync engine writes to the *same* database file, which is safe because migration scripts execute serially. """ + parsed = make_url(url) + if parsed.drivername == "sqlite+aiosqlite": + return str(parsed.set(drivername="sqlite")) return re.sub(r"^sqlite\+aiosqlite:///", "sqlite:///", url) diff --git a/tests/integration/test_e2e_lifecycle.py b/tests/integration/test_e2e_lifecycle.py index 002b750..1b0f477 100644 --- a/tests/integration/test_e2e_lifecycle.py +++ b/tests/integration/test_e2e_lifecycle.py @@ -3,6 +3,7 @@ from __future__ import annotations import datetime as dt +import ipaddress from pathlib import Path from typing import Any, ClassVar @@ -179,6 +180,10 @@ def test_full_certificate_lifecycle_with_webhooks( ) -> None: """Create, issue, deploy, renew, revoke, audit, and deliver webhooks.""" monkeypatch.setattr(main_module, "WebhookDispatcher", CapturingWebhookDispatcher) + monkeypatch.setattr( + "acme_api.webhooks._resolve_host_ips", + lambda _host: {ipaddress.ip_address("93.184.216.34")}, + ) CapturingWebhookDispatcher.reset() app = _make_app(tmp_path) diff --git a/tests/test_alembic.py b/tests/test_alembic.py index c6fa4d8..6d70491 100644 --- a/tests/test_alembic.py +++ b/tests/test_alembic.py @@ -4,6 +4,7 @@ import os import subprocess +import sys from pathlib import Path from sqlalchemy import create_engine, text @@ -68,7 +69,7 @@ class = StreamHandler env = os.environ.copy() env["PYTHONPATH"] = str(PROJECT_ROOT) result = subprocess.run( - [".venv/bin/alembic", "-c", str(ini_path), "upgrade", "head"], + [sys.executable, "-m", "alembic", "-c", str(ini_path), "upgrade", "head"], cwd=PROJECT_ROOT, env=env, text=True, diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py index d5848dd..c639e86 100644 --- a/tests/test_webhooks.py +++ b/tests/test_webhooks.py @@ -3,6 +3,7 @@ from __future__ import annotations import hmac +import ipaddress from collections.abc import AsyncGenerator from pathlib import Path @@ -26,6 +27,15 @@ ) +@pytest.fixture(autouse=True) +def _public_webhook_dns(monkeypatch: pytest.MonkeyPatch) -> None: + """Resolve test webhook hostnames to a public IP without using real DNS.""" + monkeypatch.setattr( + "acme_api.webhooks._resolve_host_ips", + lambda _host: {ipaddress.ip_address("93.184.216.34")}, + ) + + @pytest.fixture() async def session_factory( tmp_path: Path, @@ -225,3 +235,47 @@ def handler(_request: httpx2.Request) -> httpx2.Response: assert attempts == 0 assert len(events) == 1 assert "unsafe webhook url" in str(events[0].details["error"]) + + +@pytest.mark.anyio +async def test_dispatch_rejects_hostname_resolving_to_private_ip( + db_session: AsyncSession, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Webhook validation blocks hostnames that resolve to private addresses.""" + monkeypatch.setattr( + "acme_api.webhooks._resolve_host_ips", + lambda _host: {ipaddress.ip_address("10.0.0.10")}, + ) + certificate = Certificate( + name="private-host-hook-cert", + domains=["private-host-hook.example.com"], + acme_account_ref="le", + dns_provider_ref="cf", + status=CertificateStatus.VALID, + ) + db_session.add(certificate) + db_session.add( + WebhookConfig( + url="https://metadata.example.test/hook", + events=["certificate.failed"], + secret="shared-secret", + ) + ) + await db_session.commit() + + attempts = 0 + + def handler(_request: httpx2.Request) -> httpx2.Response: + nonlocal attempts + attempts += 1 + return httpx2.Response(204) + + async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as client: + async with WebhookDispatcher(db_session, client=client) as dispatcher: + delivered = await dispatcher.dispatch_certificate_event( + "certificate.failed", certificate + ) + + assert delivered == 0 + assert attempts == 0