fix: annotate to mypy-clean + promote to gating (1.0.0-40)
Some checks are pending
Security scan / pip-audit (push) Waiting to run
Security scan / bandit (push) Waiting to run
Security scan / gitleaks (push) Waiting to run
Security scan / mypy (push) Waiting to run

Five files needed annotation tweaks to clear the 14 outstanding
mypy errors, all cosmetic (zero runtime bugs):

- settings_store._coerce: return Any (concrete type depends on key,
  no narrowing path mypy can follow from the dict lookup)
- retention._state: explicit dict[str, str | None] init
- mailer: explicit `server: smtplib.SMTP` binding so SMTP_SSL and
  SMTP both narrow to the parent class for shared call sites
- burnin/stages.py: TypedDict for the badblocks result dict so
  `result["bad_blocks"]` narrows to int at the comparison site

scripts/security-scan.sh: mypy now counted in TOTAL_EXIT and
findings.log line. Comment updated to reflect gating status.
This commit is contained in:
Brandon Walter 2026-05-03 21:21:55 -07:00
parent cd92a4d3c8
commit 7cd66d460f
6 changed files with 34 additions and 13 deletions

View file

@ -14,9 +14,16 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import time import time
from typing import TypedDict
from app.config import settings from app.config import settings
class _BadblocksResult(TypedDict):
bad_blocks: int
output: str
aborted: bool
from . import kill from . import kill
from ._common import ( from ._common import (
POLL_INTERVAL, POLL_INTERVAL,
@ -385,7 +392,7 @@ async def _stage_surface_validate_ssh(job_id: int, devname: str, drive_id: int)
# Streaming + progress is handled by the inline _drain coroutines # Streaming + progress is handled by the inline _drain coroutines
# below; the in-loop _append_stage_log + _update_stage_percent calls # below; the in-loop _append_stage_log + _update_stage_percent calls
# take care of throttled DB writes. Result dict is just final tallies. # take care of throttled DB writes. Result dict is just final tallies.
result = {"bad_blocks": 0, "output": "", "aborted": False} result: _BadblocksResult = {"bad_blocks": 0, "output": "", "aborted": False}
try: try:
bad_blocks_total = 0 bad_blocks_total = 0
output_lines: list[str] = [] output_lines: list[str] = []

View file

@ -83,7 +83,7 @@ class Settings(BaseSettings):
ssh_key: str = "" # PEM private key content (paste full key including headers) ssh_key: str = "" # PEM private key content (paste full key including headers)
# Application version — used by the /api/v1/updates/check endpoint # Application version — used by the /api/v1/updates/check endpoint
app_version: str = "1.0.0-39" app_version: str = "1.0.0-40"
# ---- Authentication (1.0.0-22) ---- # ---- Authentication (1.0.0-22) ----
# session_secret: HMAC key for signing session cookies. Empty = generate # session_secret: HMAC key for signing session cookies. Empty = generate

View file

@ -303,6 +303,9 @@ def _send_email(subject: str, html: str) -> None:
timeout = int(settings.smtp_timeout or 60) timeout = int(settings.smtp_timeout or 60)
port = _smtp_port() port = _smtp_port()
# SMTP / SMTP_SSL share a parent class but mypy can't unify them
# without an explicit Union annotation on the binding.
server: smtplib.SMTP
if mode == "ssl": if mode == "ssl":
server = smtplib.SMTP_SSL(settings.smtp_host, port, context=ctx, timeout=timeout) server = smtplib.SMTP_SSL(settings.smtp_host, port, context=ctx, timeout=timeout)
server.ehlo() server.ehlo()
@ -465,6 +468,7 @@ async def test_smtp_connection() -> dict:
timeout = int(settings.smtp_timeout or 60) timeout = int(settings.smtp_timeout or 60)
port = _smtp_port() port = _smtp_port()
server: smtplib.SMTP
if mode == "ssl": if mode == "ssl":
server = smtplib.SMTP_SSL(settings.smtp_host, port, server = smtplib.SMTP_SSL(settings.smtp_host, port,
context=ctx, timeout=timeout) context=ctx, timeout=timeout)

View file

@ -124,7 +124,7 @@ async def backup_db(keep_count: int) -> Path | None:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_RUN_HOUR = 3 # 03:00 local time — quiet for most homelabs _RUN_HOUR = 3 # 03:00 local time — quiet for most homelabs
_state = {"last_run_date": None} _state: dict[str, str | None] = {"last_run_date": None}
async def run() -> None: async def run() -> None:

View file

@ -11,6 +11,7 @@ a container restart to fully take effect (clients/middleware are initialized at
import json import json
import logging import logging
from pathlib import Path from pathlib import Path
from typing import Any
from app.config import settings from app.config import settings
@ -65,7 +66,14 @@ def _overrides_path() -> Path:
return Path(settings.db_path).parent / "settings_overrides.json" return Path(settings.db_path).parent / "settings_overrides.json"
def _coerce(key: str, raw) -> object: def _coerce(key: str, raw: Any) -> Any:
"""Coerce a raw value to the type registered in _EDITABLE.
Return type is Any because the concrete return type depends on
the key int/str/bool and there's no narrowing path mypy can
follow from the dict lookup. Callers know which type to expect
based on the field they're reading.
"""
coerce = _EDITABLE[key] coerce = _EDITABLE[key]
if coerce is bool: if coerce is bool:
if isinstance(raw, bool): if isinstance(raw, bool):

View file

@ -87,17 +87,18 @@ docker run --rm \
BANDITS=$? BANDITS=$?
echo " exit=$BANDITS ($OUT_DIR/bandit.txt)" | tee -a "$OUT_DIR/summary.txt" echo " exit=$BANDITS ($OUT_DIR/bandit.txt)" | tee -a "$OUT_DIR/summary.txt"
# --- mypy against the deploy dir (informational only) ------------------- # --- mypy against the deploy dir (gating as of 1.0.0-40) ----------------
# Type checker — surfaces None-handling bugs and missing-attribute errors # Type checker — surfaces None-handling bugs and missing-attribute errors
# the runtime would have caught at the worst possible moment. Doesn't # the runtime would have caught at the worst possible moment.
# count toward the failure exit-code sum until the codebase is annotated
# enough to make findings actionable.
# #
# Mount at /opt/app/app so internal `from . import X` resolves through # Mount at /opt/app/app so internal `from . import X` resolves through
# the `app` package (not `src`). Without this the relative imports inside # the `app` package (not `src`). Without this the relative imports inside
# subpackages like burnin/ produce spurious "Module 'src' has no # subpackages like burnin/ produce spurious "Module 'src' has no
# attribute 'X'" errors that look like real bugs but are scan-env noise. # attribute 'X'" errors that look like real bugs but are scan-env noise.
echo "--- mypy (informational) ---" | tee -a "$OUT_DIR/summary.txt" #
# Now counted toward TOTAL_EXIT — the codebase is fully clean under
# `--ignore-missing-imports --no-strict-optional`. New errors fail the scan.
echo "--- mypy ---" | tee -a "$OUT_DIR/summary.txt"
docker run --rm \ docker run --rm \
-v "$DEPLOY_DIR/app:/opt/app/app:ro" \ -v "$DEPLOY_DIR/app:/opt/app/app:ro" \
-w /opt/app \ -w /opt/app \
@ -105,7 +106,7 @@ docker run --rm \
"pip install --quiet --no-cache-dir --disable-pip-version-check mypy 2>&1 | tail -3 && mypy --ignore-missing-imports --no-strict-optional app" \ "pip install --quiet --no-cache-dir --disable-pip-version-check mypy 2>&1 | tail -3 && mypy --ignore-missing-imports --no-strict-optional app" \
> "$OUT_DIR/mypy.txt" 2>&1 > "$OUT_DIR/mypy.txt" 2>&1
MYPY=$? MYPY=$?
echo " exit=$MYPY ($OUT_DIR/mypy.txt) — informational only" | tee -a "$OUT_DIR/summary.txt" echo " exit=$MYPY ($OUT_DIR/mypy.txt)" | tee -a "$OUT_DIR/summary.txt"
# --- gitleaks against the full git history ------------------------------ # --- gitleaks against the full git history ------------------------------
echo "--- gitleaks ---" | tee -a "$OUT_DIR/summary.txt" echo "--- gitleaks ---" | tee -a "$OUT_DIR/summary.txt"
@ -118,18 +119,19 @@ LEAKS=$?
echo " exit=$LEAKS ($OUT_DIR/gitleaks.txt)" | tee -a "$OUT_DIR/summary.txt" echo " exit=$LEAKS ($OUT_DIR/gitleaks.txt)" | tee -a "$OUT_DIR/summary.txt"
# --- summary + notification -------------------------------------------- # --- summary + notification --------------------------------------------
TOTAL_EXIT=$(( PIPS + BANDITS + LEAKS )) TOTAL_EXIT=$(( PIPS + BANDITS + MYPY + LEAKS ))
{ {
echo echo
echo "Total findings exit-code sum: $TOTAL_EXIT" echo "Total findings exit-code sum: $TOTAL_EXIT"
echo " pip-audit: $PIPS" echo " pip-audit: $PIPS"
echo " bandit: $BANDITS" echo " bandit: $BANDITS"
echo " mypy: $MYPY"
echo " gitleaks: $LEAKS" echo " gitleaks: $LEAKS"
} >> "$OUT_DIR/summary.txt" } >> "$OUT_DIR/summary.txt"
if [ "$TOTAL_EXIT" -ne 0 ]; then if [ "$TOTAL_EXIT" -ne 0 ]; then
printf '%s — findings (pip-audit=%d bandit=%d gitleaks=%d) — see %s\n' \ printf '%s — findings (pip-audit=%d bandit=%d mypy=%d gitleaks=%d) — see %s\n' \
"$DATE" "$PIPS" "$BANDITS" "$LEAKS" "$OUT_DIR" >> "$SUMMARY" "$DATE" "$PIPS" "$BANDITS" "$MYPY" "$LEAKS" "$OUT_DIR" >> "$SUMMARY"
# Hook for downstream notification — wire to your existing Mattermost # Hook for downstream notification — wire to your existing Mattermost
# / Fastmail / webhook chain. Stays a no-op until SECURITY_SCAN_WEBHOOK # / Fastmail / webhook chain. Stays a no-op until SECURITY_SCAN_WEBHOOK
# is set in the systemd unit's Environment=. # is set in the systemd unit's Environment=.