Author SHA1 Message Date
Codex 5bd97402bf fix(cli): exposer les executions degradees 2026-09-12 15:37:11 +02:00
8 changed files with 68 additions and 193 deletions
+11 -10
View File
@@ -66,12 +66,6 @@ processus. Le mode `PRONOTE_AUTH_MODE=qr_token` est incompatible avec cette gara
le refuse avant toute connexion afin de ne pas désynchroniser le token local du token distant.
Le dry-run ne remplace pas une vérification des paramètres réellement chargés.
Si le blog RSS est activé, ses GUID ne sont acquittés qu'après confirmation de
l'envoi XMPP. Un refus, une exception, l'absence de canal ou un `--dry-run`
laisse donc les articles récupérables à l'exécution suivante ; les en-têtes
HTTP associés à ces articles suivent la même règle pour éviter un `304` qui
masquerait une livraison non confirmée.
En mode `PRONOTE_AUTH_MODE=qr_token`, le fichier
`.pronote_auth_state.json` et son verrou frère sont créés dans le répertoire
de travail du service (par exemple `/var/lib/pronote-sync`) avec le mode
@@ -111,10 +105,17 @@ sudo systemctl start pronote-sync.service
sudo systemctl status pronote-sync.service
```
Une exécution en échec laisse l'unité `pronote-sync.service` en état `failed`.
La supervision de l'hôte doit donc déclencher une alerte sur cet état ou sur un
échec du timer/service ; le transport de cette alerte (courriel, XMPP ou système
de supervision) relève de l'exploitation locale.
La CLI expose un contrat de sortie stable : `0` signifie une exécution complète,
`2` une exécution dégradée (les données Pronote sont disponibles mais une étape
optionnelle, CalDAV ou XMPP a échoué), et `1` un échec critique. Tout code non
nul laisse l'unité `pronote-sync.service` en état `failed` ; la supervision doit
donc alerter sur cet état ou sur le code de sortie. Le code `2` permet de
distinguer automatiquement une alerte dégradée d'une panne critique, sans lire
les journaux.
Le `--dry-run` n'écrit ni dans CalDAV/XMPP ni dans l'état local. Il conserve le
même contrat de codes : `0` si la simulation est complète, `2` si elle est
dégradée et `1` si elle est critique.
## Journaux et alertes
+23 -4
View File
@@ -11,6 +11,7 @@ from pydantic import SecretStr
from pronote_sync.config.env import load_settings
from pronote_sync.config.settings import Settings
from pronote_sync.errors import ErrorSeverity, PipelineError
from pronote_sync.pipeline.run import PipelineRunner
from pronote_sync.utils.logging import setup_logging
from pronote_sync.utils.redaction import redact_secrets
@@ -19,6 +20,26 @@ logger = logging.getLogger(__name__)
_LOG_LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL")
# Contrat stable pour systemd et les outils de supervision.
EXIT_SUCCESS = 0
EXIT_CRITICAL = 1
EXIT_DEGRADED = 2
def _pipeline_exit_code(data: object | None, errors: Sequence[PipelineError]) -> int:
"""Convertit le résultat du pipeline en code de sortie supervisable.
:param data: Données normalisées produites, ou ``None`` en cas d'échec critique.
:param errors: Erreurs et avertissements de l'exécution.
:return: ``0`` si complet, ``2`` si dégradé, ``1`` si critique.
:rtype: int
"""
if data is None or any(error.severity == ErrorSeverity.CRITICAL for error in errors):
return EXIT_CRITICAL
if errors:
return EXIT_DEGRADED
return EXIT_SUCCESS
def _parse_arguments(arguments: Sequence[str] | None = None) -> argparse.Namespace:
"""Analyse les options de lancement du programme.
@@ -120,7 +141,7 @@ def main(arguments: Sequence[str] | None = None) -> int:
bruts afin de préserver le diagnostic sans exposer de secret.
:param arguments: Arguments optionnels, principalement utiles aux appels programmatiques.
:return: ``0`` en cas de succès, ``1`` sinon (après analyse des arguments).
:return: Code machine-readable : ``0`` complet, ``2`` dégradé, ``1`` critique.
:rtype: int
:raises SystemExit: Si argparse rejette les arguments (code de sortie 2).
"""
@@ -147,9 +168,7 @@ def main(arguments: Sequence[str] | None = None) -> int:
secrets = _settings_secrets(settings)
for error in errors:
logger.error("%s", redact_secrets(error.message, extra_secrets=secrets))
if data is None:
return 1
return 0
return _pipeline_exit_code(data, errors)
if __name__ == "__main__":
+2 -7
View File
@@ -230,13 +230,11 @@ class PipelineRunner:
data = normalize_step(fetched, generated_at=now)
try:
blog_result = fetch_blog_step(self._blog_client, self._blog_state)
blog_articles = list(blog_result.articles)
blog_articles = fetch_blog_step(self._blog_client, self._blog_state)
except PipelineCriticalError:
raise
except Exception as exc:
self._warn("fetch_blog", self._redact(exc))
blog_result = None
blog_articles = []
try:
@@ -292,11 +290,8 @@ class PipelineRunner:
)
if self._channel is not None and not self._dry_run:
try:
delivered = send_step(self._channel, message)
if not delivered:
if not send_step(self._channel, message):
self._warn("send", "Le canal XMPP a refusé l'envoi")
elif blog_result is not None and self._blog_state is not None:
self._blog_state.acknowledge(blog_result)
except PipelineCriticalError:
raise
except Exception as exc:
+9 -13
View File
@@ -2,28 +2,23 @@
from __future__ import annotations
from pronote_sync.sources.blog.result import BlogRSSFetchResult
from pronote_sync.models.blog import BlogArticle
from pronote_sync.sources.blog.rss import BlogRSSClient
from pronote_sync.sources.blog.state import BlogRSSState
from pronote_sync.utils.redaction import redact_exception
def fetch_blog_step(client: BlogRSSClient | None, state: BlogRSSState | None) -> BlogRSSFetchResult:
"""Récupère les articles RSS nouveaux sans les acquitter.
L'état des GUID est acquitté séparément par le pipeline après confirmation
de la livraison XMPP. Les en-têtes de cache d'une réponse sans article
peuvent être conservés immédiatement, car aucune livraison n'est alors en
attente.
def fetch_blog_step(client: BlogRSSClient | None, state: BlogRSSState | None) -> list[BlogArticle]:
"""Récupère les articles RSS nouveaux en conservant l'état du client.
:param client: Client RSS configuré, ou ``None`` lorsque le blog est désactivé.
:param state: État de déduplication et de cache HTTP associé au run.
:return: Résultat de récupération, incluant les métadonnées de cache.
:rtype: BlogRSSFetchResult
:return: Nouveaux articles du blog.
:rtype: list[BlogArticle]
:raises RuntimeError: Si la récupération RSS injectée échoue.
"""
if client is None or state is None:
return BlogRSSFetchResult()
return []
try:
etag, last_modified = state.get_cache_headers()
result = client.fetch_and_parse(
@@ -31,8 +26,9 @@ def fetch_blog_step(client: BlogRSSClient | None, state: BlogRSSState | None) ->
)
if result.error is not None:
raise RuntimeError(result.error) from None
if not result.not_modified and not result.articles:
if not result.not_modified:
state.add_guids(article.id for article in result.articles)
state.update_cache_headers(result.etag, result.last_modified)
return result
return list(result.articles)
except Exception as exc:
raise RuntimeError(f"Récupération du blog échouée : {redact_exception(exc)}") from None
-17
View File
@@ -19,7 +19,6 @@ import logging
from collections.abc import Iterable
from pathlib import Path
from pronote_sync.sources.blog.result import BlogRSSFetchResult
from pronote_sync.utils.redaction import redact_exception, redact_secrets
logger = logging.getLogger(__name__)
@@ -157,22 +156,6 @@ class BlogRSSState:
self._known_guids.update(new_guids)
self._save()
def acknowledge(self, result: BlogRSSFetchResult) -> None:
"""Acquitte une récupération RSS après sa livraison confirmée.
Les GUID et les en-têtes de cache sont enregistrés ensemble afin qu'un
article dont la livraison a échoué reste récupérable à l'exécution
suivante. Une réponse ``304 Not Modified`` n'a rien à acquitter.
:param result: Résultat RSS livré avec succès.
"""
if result.not_modified:
return
self._known_guids.update(article.id for article in result.articles)
self._etag = result.etag
self._last_modified = result.last_modified
self._save()
def get_cache_headers(self) -> tuple[str | None, str | None]:
"""Renvoie les en-têtes de cache HTTP mémorisés.
+23 -3
View File
@@ -52,10 +52,10 @@ def test_main_runs_composition_root_in_dry_run_with_requested_log_level(
runner.run.assert_called_once_with()
def test_main_preserves_configured_dry_run_and_returns_success_with_warnings(
def test_main_preserves_configured_dry_run_and_returns_degraded_with_warnings(
mocker: MockerFixture,
) -> None:
"""Sans option, la CLI préserve le dry-run configuré et accepte les avertissements."""
"""Sans option, la CLI préserve le dry-run configuré et signale l'état dégradé."""
from pronote_sync.cli.main import main
settings = Settings(app=AppSettings(dry_run=True, log_level="WARNING"))
@@ -72,12 +72,32 @@ def test_main_preserves_configured_dry_run_and_returns_success_with_warnings(
exit_code = main([])
assert exit_code == 0
assert exit_code == 2
assert setup_logging.call_args_list == [mocker.call("INFO"), mocker.call("WARNING")]
composition_root.assert_called_once_with(settings, dry_run=None)
runner.run.assert_called_once_with()
@pytest.mark.parametrize("step", ["caldav_sync", "send"])
def test_main_returns_degraded_code_for_caldav_or_xmpp_failure(
mocker: MockerFixture,
step: str,
) -> None:
"""Les échecs récupérables CalDAV et XMPP sont observables par le code 2."""
from pronote_sync.cli.main import main
settings = Settings()
mocker.patch("pronote_sync.cli.main.load_settings", return_value=settings)
runner = mocker.Mock()
runner.run.return_value = (
mocker.Mock(spec=PronoteData),
[PipelineWarning(f"Échec récupérable de {step}", step=step)],
)
mocker.patch("pronote_sync.cli.main.PipelineRunner.from_settings", return_value=runner)
assert main([]) == 2
def test_main_returns_failure_and_redacts_pipeline_secrets_at_debug_level(
mocker: MockerFixture,
capsys: pytest.CaptureFixture[str],
-104
View File
@@ -1040,110 +1040,6 @@ def test_runner_blog_success_delivers_articles_into_xmpp_message_external_info(
assert xmpp_message.external_info.blog_articles[0].title == "Test Article"
@pytest.mark.parametrize(
("channel_kind", "dry_run", "should_acknowledge"),
[
("success", False, True),
("false", False, False),
("exception", False, False),
("none", False, False),
("success", True, False),
],
)
def test_runner_acknowledges_blog_only_after_confirmed_xmpp_delivery(
pipeline_inputs: tuple[Lesson, Homework],
tmp_path: Any,
channel_kind: str,
dry_run: bool,
should_acknowledge: bool,
) -> None:
"""Les GUID RSS restent rejouables tant que XMPP n'a pas confirmé l'envoi.
:param pipeline_inputs: Données Pronote de test.
:param tmp_path: Répertoire temporaire pour l'état RSS.
:param channel_kind: Comportement du canal XMPP simulé.
:param dry_run: Active ou non le mode simulation.
:param should_acknowledge: Indique si l'état RSS doit être acquitté.
"""
lesson, homework = pipeline_inputs
calls: list[str] = []
state_file = tmp_path / "blog-state.json"
class SuccessfulBlogClient:
"""Client RSS renvoyant un article non encore livré."""
def fetch_and_parse(
self,
*,
known_guids: frozenset[str] | None = None,
etag: str | None = None,
last_modified: str | None = None,
) -> BlogRSSFetchResult:
"""Retourne un article et des en-têtes de cache déterministes.
:param known_guids: GUID déjà connus, ignorés dans ce faux client.
:param etag: ETag mémorisé, ignoré dans ce faux client.
:param last_modified: Date HTTP mémorisée, ignorée dans ce faux client.
:return: Résultat RSS avec un article à livrer.
:rtype: BlogRSSFetchResult
"""
del known_guids, etag, last_modified
return BlogRSSFetchResult(
articles=(
BlogArticle(
id="article-to-deliver",
title="Article à livrer",
url="https://example.com/article-to-deliver",
published_at=datetime(2026, 9, 8, 12, 0),
updated_at=None,
category=None,
author=None,
content_html="<p>Contenu</p>",
content_text="Contenu",
),
),
etag="etag-after-delivery",
last_modified="Tue, 08 Sep 2026 12:00:00 GMT",
)
channel: Any
if channel_kind == "success":
channel = StubChannel(calls)
elif channel_kind == "false":
channel = FailingChannel()
elif channel_kind == "exception":
channel = ExceptionalChannel()
else:
channel = None
runner = PipelineRunner(
settings=Settings(blog=Settings().blog.model_copy(update={"enabled": True})),
pronote_fetcher=StubFetcher(calls, lesson, homework),
caldav_synchronizer=lambda data, settings: successful_sync_result(),
agenda_comparator=cast("AgendaComparator | None", StubComparator(calls)),
blog_client=cast("BlogRSSClient | None", SuccessfulBlogClient()),
blog_state=BlogRSSState(state_file),
channel=channel,
dry_run=dry_run,
now_provider=lambda: datetime(2026, 9, 8, 7, 0),
)
data, errors = runner.run()
assert data is not None
if should_acknowledge:
acknowledged_state = BlogRSSState(state_file)
assert acknowledged_state.get_known_guids() == frozenset({"article-to-deliver"})
assert acknowledged_state.get_cache_headers() == (
"etag-after-delivery",
"Tue, 08 Sep 2026 12:00:00 GMT",
)
else:
assert not state_file.exists()
if channel_kind in {"false", "exception"}:
assert any(error.step == "send" for error in errors)
def test_runner_secret_redaction_in_pipeline_errors(
pipeline_inputs: tuple[Lesson, Homework],
) -> None:
-35
View File
@@ -14,14 +14,11 @@ Tous les tests utilisent des fichiers temporaires via la fixture ``tmp_path``.
from __future__ import annotations
import json
from datetime import UTC, datetime
from pathlib import Path
from unittest.mock import patch
import pytest
from pronote_sync.models.blog import BlogArticle
from pronote_sync.sources.blog.result import BlogRSSFetchResult
from pronote_sync.sources.blog.state import BlogRSSState
@@ -119,38 +116,6 @@ def test_add_guids_empty_noop(tmp_path: Path) -> None:
assert state_file.read_text(encoding="utf-8") == original_content
def test_acknowledge_persists_guids_and_cache_headers_together(tmp_path: Path) -> None:
"""Vérifie l'acquittement atomique après une livraison confirmée.
:param tmp_path: Fixture pytest pour un répertoire temporaire.
:return: None
"""
state_file = tmp_path / "state.json"
state = BlogRSSState(state_file)
article = BlogArticle(
id="guid-1",
title="Article",
url="https://example.com/article",
published_at=datetime(2026, 9, 12, 8, 0, tzinfo=UTC),
updated_at=None,
category=None,
author=None,
content_html="<p>Contenu</p>",
content_text="Contenu",
)
state.acknowledge(
BlogRSSFetchResult(
articles=(article,),
etag="etag-1",
last_modified="Sat, 12 Sep 2026 08:00:00 GMT",
)
)
assert state.get_known_guids() == frozenset({"guid-1"})
assert state.get_cache_headers() == ("etag-1", "Sat, 12 Sep 2026 08:00:00 GMT")
def test_state_load_persisted_guids(tmp_path: Path) -> None:
"""Vérifie que les GUID persistés sont rechargés dans une nouvelle instance.