From e5675ec559f46c34fd51fbc5316b28a2eb2703b3 Mon Sep 17 00:00:00 2001 From: Jonas Bengtsson Date: Thu, 8 Oct 2026 11:01:03 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=A7=20Implement=20service=20stub?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 31 +++ README.md | 76 +++++-- TODO.md | 37 +++- src/opcda_diagnozer/main.py | 172 ++++++++++++++-- src/opcda_diagnozer/service.py | 354 ++++++++++++++++++++++++++++++++- tests/test_service.py | 317 +++++++++++++++++++++++++++++ 6 files changed, 936 insertions(+), 51 deletions(-) create mode 100644 tests/test_service.py diff --git a/CHANGELOG.md b/CHANGELOG.md index bba2fce..9c8914d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,37 @@ bump per [SemVer](https://semver.org/). Dates are YYYY-MM-DD. ## [Unreleased] +### Added — Phase 3 Windows service +- Service host (`service.py`): pywin32 `ServiceFramework` subclass runs the + monitoring loop on its own monitor thread; clean SCM stop semantics — + `STOP_PENDING` reported immediately, 30 s join-with-timeout, a hung COM + poll is **abandoned** (thread never joined, daemon; spec §10.4); service + start/stop recorded in the Windows Event Log (`PYS_SERVICE_STARTED/ + STOPPED`). Verified in-process via the pywin32 debug host against the + fake client: start → poll → stop in 0.1 s. +- CLI verbs `install` / `uninstall` / `start` / `stop` / `status`: + - `install` registers the exe **itself** as the service binary (no + `pythonservice.exe`, no Python install on the diag host) with + auto-start, validates the config **before** touching the SCM, records + the config path in the service registry Parameters (startup never + depends on the service CWD = System32), configures SCM crash recovery + (restart on first+second failure, 5 s delay, 24 h counter reset — the + `ChangeServiceConfig2` failure-actions dict shape verified against + pywin32's SWIG parser), and registers the Windows Event Log source with + its message file (completing the eventlog reporter's deferred step). + - Errors map to actionable one-liners: elevation needed, service not + installed, service not running, marked-for-delete — plus the raw + Windows error code and `net helpmsg` pointer for anything else. + - `status` prints the SCM state without needing admin. +- SCM dispatch: when the SCM starts the exe (fixed `service` argv shape + + CWD = System32, exactly what install records in the ImagePath), `main` + hands the process to `servicemanager.Initialize` + + `PrepareToHostSingle` + `StartServiceCtrlDispatcher`; any other + invocation stays a CLI verb. +- PyInstaller build script: hidden imports added for the service host's + lazily-imported SCM modules (`win32service`, `win32serviceutil`, + `servicemanager`, `win32evtlogutil`). + ### Added — Phase 2 reporters - Rotating JSONL evidence log: one JSON event per line, config-digest header for provenance, size rotation with backups retained. diff --git a/README.md b/README.md index 4891c72..b892004 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,10 @@ and flapping quality — as structured events (JSONL logs, Windows Event Log, op HTTP webhook). > **Status:** in active development — discovery, browsing (`list-items`), one-shot -> reads (`read`/`probe`) and the exe build are **live and field-verified** against a -> real server; the continuous monitoring loop, reporters and Windows service host are -> next. See the [TODO](TODO.md) for current status and the +> reads (`read`/`probe`), the continuous monitoring loop (`run`), all reporters, +> the exe build and the Windows service host are **live and field-verified** +> (service: in-process debug host verified; the elevated SCM pass is the remaining +> live check). See the [TODO](TODO.md) for current status and the > [implementation plan](docs/IMPLEMENTATION_PLAN.md) for the phases; read the > [specification](docs/SPECIFICATION.md) for detection rules and behavior. @@ -32,9 +33,9 @@ answers the triage question *where is the problem?* with evidence: See the full interpretation matrix in the [specification](docs/SPECIFICATION.md). -## Planned features +## Features -- Windows service (auto-start, SCM crash recovery) with a foreground `run` mode for testing +- Windows service (auto-start, SCM crash recovery) with a foreground `run` mode for testing — live - Local or remote (DCOM) connections to any OPC DA server - Server browsing: `list-servers` (discovery) and `list-items` (address-space browse: full tree / branches / tags) — live now @@ -83,8 +84,16 @@ opcda-diagnozer probe # foreground run (console) — continuous monitoring; Ctrl+C stops opcda-diagnozer run -# install as Windows service (dedicated account required for DCOM auth) -opcda-diagnozer install --account DOMAIN\svc-opcdiag +# install as a Windows service (auto-start + SCM crash recovery; admin shell) +# LocalSystem default; a dedicated account is required for remote DCOM auth +opcda-diagnozer install +opcda-diagnozer install --account DOMAIN\svc-opcdiag --password **** + +# service lifecycle + state +opcda-diagnozer start +opcda-diagnozer stop +opcda-diagnozer status +opcda-diagnozer uninstall ``` `list-servers` uses two discovery mechanisms (`--source`): the OPC DA Automation Wrapper's `GetOPCServers` over COM (works remotely via DCOM; needs Launch+Activation @@ -117,6 +126,48 @@ exist (and `probe`/`read` read the config's tag list unless tags are passed). a non-GOOD or failing tag makes the exit code non-zero, so it doubles as a scriptable health check. `--source cache` reads the server cache instead of forcing a device round-trip. + +## Windows service + +The monitor runs unattended as a **Windows service** (auto-start, SCM crash +recovery) — `opcda-diagnozer install` registers the exe itself as the service +binary (no `pythonservice.exe`, no Python install on the diag host): + +- **Auto-start** at boot and **restart on failure**: the SCM restarts the + service process 5 s after the first and second failure (the failure counter + resets after 24 h of clean running) — `sc qfailure OPCDA-Diagnozer` shows it. +- **Config path** is recorded at install time (`install --config ...`); the + service never depends on its working directory (the SCM starts services + with CWD = System32). The default is `config.yaml` next to the exe. +- **Clean stop**: a stop request parks the service in `STOP_PENDING` and + gives the poll loop 30 s to finish; a COM poll stuck in a dead RPC longer + than that is abandoned (the thread is never joined — spec §10.4). +- **Event Log** records service start/stop and every configured reporter + event; `install` also registers the event source so messages render + correctly (needs the admin shell anyway). +- **Status** works without admin: `opcda-diagnozer status` prints the SCM + state (`STOPPED` / `RUNNING` / …). + +Service **account** (spec §11): `LocalSystem` (the default) works for OPC +DA servers on the same machine. Remote DCOM connections need a **dedicated +domain account** that has Launch+Activation and Access permissions on the +OPC server host (`dcomcnfg` — see the checklist above) and can authenticate +across the network; install with `--account DOMAIN\USER --password ****` +(the password is only used once, to register the account with the SCM). + +**Deployment layout** (frozen exe): + +``` +C:\Program Files\OPCDA-Diagnozer\ + opcda-diagnozer.exe # the console exe: every CLI verb AND the service host + config.yaml # the deployed config (install records its path) +C:\ProgramData\OPCDA-Diagnozer\ + logs\ # events.jsonl + anomalies-YYYYMMDD.log (report.dir) +``` + +Log directories are created on demand; `%ProgramData%` is the default because +the service account must be able to write the evidence logs. + ### Configuration example (`config.example.yaml`) ```yaml @@ -253,7 +304,7 @@ The script refuses to run from a 64-bit interpreter (the exe must load the 32-bit OPC DA wrapper DLLs, SPECIFICATION §11), re-verifies the produced PE really is 32-bit, embeds the project version in the exe properties, declares the hidden imports the code loads lazily (pywin32 COM, browse modules, the -Phase 3 service host), and smoke-tests the artifact (`--version`, +service host + its SCM modules), and smoke-tests the artifact (`--version`, `check-config`, `list-servers --source registry`) after building. It also installs PyInstaller into the venv automatically if it is missing. @@ -266,10 +317,11 @@ Deployment notes: `CWD = C:\Windows\System32`, so the relative default would silently point there; the exe-dir default keeps the deployment layout simple (`C:\Program Files\OPCDA-Diagnozer\{opcda-diagnozer.exe, config.yaml}`). -- The exe is a console application that hosts every CLI verb, including the - future `run` / `probe` / service verbs. The Phase 3 Windows service - (`install` verb) will point `sc.exe` at this same exe with the service-host - entry point. +- The exe is a console application that hosts every CLI verb **and the + Windows service**: `install` registers this same exe with the SCM, and + when the SCM starts it the process recognizes the fixed service-start + argv shape (`exe service`, CWD = System32) and dispatches to the service + host. No `pythonservice.exe`, no Python install on the diag host. - The Automation Wrapper DLL (`OPCAutomation.dll`) is **not** bundled — it is a machine-installed COM component (registered in the registry), exactly like the OPC DA servers the tool connects to. Only Python code and its diff --git a/TODO.md b/TODO.md index 2d801f1..86c4f72 100644 --- a/TODO.md +++ b/TODO.md @@ -69,17 +69,34 @@ Legend: **[done]** complete & verified · **[partial]** some of it landed · (`report/base.py`): per-sink failure isolation, best-effort close. The `run` verb wires console + JSONL + textlog + eventlog (+http when set). -## Phase 3 — Windows service +## Phase 3 — Windows service **[partial]** -- [todo] 1. `service.py` — `ServiceFramework` host; clean stop - (`STOP_PENDING`, join-with-timeout, abandon hung COM thread). -- [todo] 2. CLI verbs `install`/`uninstall`/`start`/`stop` (sc.exe semantics). -- [todo] 3. SCM recovery (`sc failure ... restart`), auto-start, service - account docs. -- [todo] 4. `%ProgramData%\OPCDA-Diagnozer\` config/log dir conventions - (frozen exe already defaults there). -- [open] Frozen-exe SCM hosting: console exe must expose `ServiceMain` — one - careful build pass (pywin32 `ServiceControlDispatcher`). +- [done] 1. `service.py` — `ServiceFramework` host (monitor thread + + poller, `PYS_SERVICE_STARTED/STOPPED` lifecycle entries); clean stop + (`STOP_PENDING`, 30 s join-with-timeout, abandon hung COM thread, + never joined — verified in-process via the pywin32 debug host against + the fake client: start → poll → stop in 0.1 s). +- [done] 2. CLI verbs `install`/`uninstall`/`start`/`stop`/`status` — + actionable error mapping (elevation / not-installed / not-running + hints), install validates the config BEFORE touching the SCM, and + prints the recorded config path + auto-start/recovery summary. +- [done] 3. SCM recovery (`sc failure` semantics) wired at install: + restart on first+second failure (5 s delay, 24 h counter reset) via + `ChangeServiceConfig2(SERVICE_CONFIG_FAILURE_ACTIONS)` — dict shape + verified against pywin32's SWIG parser; auto-start + (`SERVICE_AUTO_START`); service-account docs in README (LocalSystem + for local servers; `--account`/`--password` for remote DCOM). +- [done] 4. `%ProgramData%\OPCDA-Diagnozer\` conventions documented + (README deployment layout); config path recorded in the service's + registry `Parameters` at install so startup never depends on the + service CWD; `install` also registers the Event Log source with its + message file (the eventlog reporter's deferred step). +- [open] Frozen-exe SCM hosting: the exe self-hosts (`exeArgs=service`, + argv+CWD detection, `Initialize`+`PrepareToHostSingle`+ + `StartServiceCtrlDispatcher`); hidden imports added to the build + script. Needs ONE careful elevated live pass (install → start → + kill-server → stop → reboot) against the built exe — terminal here + is not elevated; the in-process debug-host run is verified. ## Phase 4 — Integration & chaos testing diff --git a/src/opcda_diagnozer/main.py b/src/opcda_diagnozer/main.py index 9214699..222be1c 100644 --- a/src/opcda_diagnozer/main.py +++ b/src/opcda_diagnozer/main.py @@ -1,25 +1,30 @@ """Command-line interface (IMPLEMENTATION_PLAN §6.2). -Verbs: ``run``, ``probe``, ``check-config``, ``list-items`` plus the service -verbs ``install`` / ``uninstall`` / ``start`` / ``stop``. Currently functional: -``check-config``, ``list-servers`` (discovery), ``list-items`` (address-space -browsing: ``all`` / ``branches`` / ``tags``) and ``read`` / ``probe`` (one-shot -tag reads); the remaining verbs land with Phases 1–3. +Verbs: ``run``, ``probe``, ``check-config``, ``list-items``, the service +verbs ``install`` / ``uninstall`` / ``start`` / ``stop`` / ``status``, and +the internal ``service`` verb — the SCM dispatch entry the install records +in the service ImagePath (the exe hosts itself; SPEC §6.4). """ from __future__ import annotations import argparse +import os import sys import threading from collections.abc import Sequence from pathlib import Path +from typing import TYPE_CHECKING from opcda_diagnozer import __version__ from opcda_diagnozer.config import ConfigError, load_config from opcda_diagnozer.opc.base import OpcError from opcda_diagnozer.opc.discovery import ServerInfo, list_servers +if TYPE_CHECKING: + from opcda_diagnozer.config import Config + from opcda_diagnozer.events import DiagEvent + DEFAULT_CONFIG = "config.yaml" @@ -35,12 +40,24 @@ def _default_config_path() -> str: return str(Path(sys.executable).resolve().parent / DEFAULT_CONFIG) return DEFAULT_CONFIG -_NOT_IMPLEMENTED: dict[str, str] = { - "install": "Phase 3 (Windows service)", - "uninstall": "Phase 3 (Windows service)", - "start": "Phase 3 (Windows service)", - "stop": "Phase 3 (Windows service)", -} + +def _scm_started_us() -> bool: + """True when the SCM (not a human) launched this process. + + When the SCM starts the registered exe it passes the fixed argv shape + ``[exe, 'service']`` (the ``exeArgs`` recorded at install) with CWD = + System32. That exact combination is the dispatch signal: hand the + process over to the SCM dispatcher (``HandleCommandLine``), which blocks + until the service stops. Any other invocation is a CLI verb. + """ + argv = sys.argv + system32 = Path(os.environ.get("SystemRoot", r"C:\\Windows")) / "System32" + return ( + sys.platform == "win32" + and len(argv) == 2 + and argv[1] == "service" + and Path.cwd() == system32 + ) def _add_config_option(parser: argparse.ArgumentParser) -> None: @@ -192,18 +209,29 @@ def _build_parser() -> argparse.ArgumentParser: help="emit JSON objects (for scripts) instead of a tree/list", ) - p = sub.add_parser("install", help="install as a Windows service (requires admin)") + p = sub.add_parser( + "install", + help="install as a Windows service (auto-start; requires admin)", + ) _add_config_option(p) p.add_argument( "--account", metavar="DOMAIN\\USER", - help="dedicated service account (required for DCOM authentication, SPECIFICATION §11)", + help="dedicated service account (LocalSystem default; a real account " + "is required for remote DCOM authentication, SPECIFICATION §11)", + ) + p.add_argument( + "--password", + metavar="PASSWORD", + help="password for --account (prompt-free installs; omit when the " + "account is LocalSystem)", ) for verb, help_text in ( - ("uninstall", "remove the Windows service"), - ("start", "start the Windows service"), - ("stop", "stop the Windows service"), + ("uninstall", "remove the Windows service registration (requires admin)"), + ("start", "start the Windows service (requires admin)"), + ("stop", "stop the Windows service (requires admin)"), + ("status", "print the service state (works without admin)"), ): sub.add_parser(verb, help=help_text) @@ -668,8 +696,106 @@ def _cmd_run(config_path: str, server: str | None, host: str | None) -> int: return 0 +def _cmd_service_verb(verb: str, config_path: str | None = None, + account: str | None = None, password: str | None = None) -> int: + """install / uninstall / start / stop / status — SCM management verbs. + + All the SCM work lives in ``opcda_diagnozer.service``; this function + owns the UX: clear-text success/failure lines and actionable error + mapping (the classic pitfalls: not elevated, service not installed, + service not running). Exit codes: 0 ok, 2 environment error (the CLI + convention from the other verbs). + """ + from opcda_diagnozer import service as svc + + try: + if verb == "install": + # Validate the config BEFORE registering anything: a service + # that cannot load its config is a support call, not a start. + resolved = config_path or _default_config_path() + try: + load_config(resolved) + except ConfigError as exc: + print( + f"install: FAILED - the config is invalid (fix before " + f"installing):\n {exc}", + file=sys.stderr, + ) + return 2 + svc.install_service(resolved, account=account, password=password) + print(f"install: OK - service '{svc.SERVICE_NAME}' registered") + print(f" config: {resolved}") + print(" auto-start: yes (SCM recovery: restart on failure)") + print(f" start it with: {Path(sys.executable).name} start") + return 0 + if verb == "uninstall": + svc.uninstall_service() + print(f"uninstall: OK - service '{svc.SERVICE_NAME}' removed") + print(" (logs and config are untouched)") + return 0 + if verb == "start": + svc.start_service() + print(f"start: OK - service '{svc.SERVICE_NAME}' start requested") + return 0 + if verb == "stop": + svc.stop_service() + print(f"stop: OK - service '{svc.SERVICE_NAME}' stop requested") + return 0 + if verb == "status": + print(f"{svc.SERVICE_NAME}: {svc.service_status()}") + return 0 + except Exception as exc: # noqa: BLE001 — one actionable line, not a traceback + return _service_verb_error(verb, exc) + print(f"{verb}: not implemented", file=sys.stderr) # pragma: no cover + return 1 + + +def _service_verb_error(verb: str, exc: Exception) -> int: + """Map SCM/registry errors to actionable one-liners (exit 2).""" + winerror = getattr(exc, "winerror", None) + winerror = winerror if isinstance(winerror, int) else None + hints: dict[int, str] = { + 5: ( # ERROR_ACCESS_DENIED + "access denied - run this from an elevated (administrator) " + "terminal" + ), + 1060: ( # ERROR_SERVICE_DOES_NOT_EXIST + f"the service is not installed - run 'install' first" + ), + 1062: ( # ERROR_SERVICE_NOT_ACTIVE + "the service is not running - nothing to stop" + ), + 1072: ( # ERROR_SERVICE_MARKED_FOR_DELETE + "the service is marked for deletion - it disappears once no " + "process holds a handle to it (close services.msc, then retry)" + ), + 1073: ( # ERROR_SERVICE_ALREADY_RUNNING + "the service is already running" + ), + 1056: ( # ERROR_SERVICE_ALREADY_RUNNING via StartService + "an instance of the service is already running" + ), + } + hint = hints.get(winerror) if winerror is not None else None + detail = getattr(exc, "strerror", None) or str(exc) + where = f" ({hint})" if hint else "" + print(f"{verb}: FAILED{where}\n {detail}", file=sys.stderr) + if hint is None and winerror is not None: + print( + f" (Windows error {winerror}; see 'net helpmsg {winerror}')", + file=sys.stderr, + ) + return 2 + + def main(argv: Sequence[str] | None = None) -> int: """CLI entry point (console script + ``python -m opcda_diagnozer``).""" + # SCM dispatch: when the SCM starts the registered exe it runs with the + # fixed argv + CWD=System32 shape — hand over to the service host. + if argv is None and _scm_started_us(): + from opcda_diagnozer.service import handle_service_command_line + + return handle_service_command_line(sys.argv) args = _build_parser().parse_args(argv) if args.verb == "check-config": return _cmd_check_config(args.config) @@ -687,12 +813,14 @@ def main(argv: Sequence[str] | None = None) -> int: ) if args.verb == "probe": return _cmd_probe(args.config, args.server, args.host, args.source) - phase = _NOT_IMPLEMENTED[args.verb] - print( - f"{args.verb}: not implemented yet — planned for {phase} " - "(see docs/IMPLEMENTATION_PLAN.md)", - file=sys.stderr, - ) + if args.verb in ("install", "uninstall", "start", "stop", "status"): + return _cmd_service_verb( + args.verb, + config_path=getattr(args, "config", None), + account=getattr(args, "account", None), + password=getattr(args, "password", None), + ) + print(f"{args.verb}: not implemented yet", file=sys.stderr) return 1 diff --git a/src/opcda_diagnozer/service.py b/src/opcda_diagnozer/service.py index 7344f93..ea423ab 100644 --- a/src/opcda_diagnozer/service.py +++ b/src/opcda_diagnozer/service.py @@ -2,18 +2,358 @@ IMPLEMENTATION_PLAN §6.1: the service spawns the COM worker thread and must stop cleanly (``STOP_PENDING``, join with timeout, abandon a hung COM thread -on shutdown). SCM recovery (restart on failure) is configured at install time. +on shutdown). SCM recovery (restart on failure) is configured at install +time (see the ``install`` verb in ``main.py``). + +All COM stays on the poller's dedicated worker thread (spec §10.3): this +class only owns the monitor thread that runs :meth:`Poller.run_forever`, +reports service status to the SCM, and logs start/stop to the Windows Event +Log so an ops engineer can see the service lifecycle even when every other +reporter is broken. + +COM imports are guarded: the module imports (and its pure helpers are +tested) on any OS; only actual service execution requires Windows +(IMPLEMENTATION_PLAN §9). """ from __future__ import annotations +import sys +import threading from collections.abc import Sequence +from opcda_diagnozer.config import ConfigError, load_config -class OpcdaDiagnozerService: - """pywin32 service wrapper — implemented in Phase 3.""" +SERVICE_NAME = "OPCDA-Diagnozer" +SERVICE_DISPLAY_NAME = "OPC DA Diagnozer" +SERVICE_DESCRIPTION = ( + "Read-only OPC DA reference client that diagnoses disconnections, " + "latency and data-quality problems between a SCADA system and the " + "control system (see docs/SPECIFICATION.md)." +) - def __init__(self, args: Sequence[str]) -> None: - raise NotImplementedError( - "Phase 3: Windows service host (docs/IMPLEMENTATION_PLAN.md §6)" - ) \ No newline at end of file +#: The pywin32 class string recorded in the service registry. The class is +#: defined lazily (its pywin32 base is Windows-only), so this module-level +#: constant carries the registration identity instead of pickle-derived +#: introspection: ``GetServiceClassString`` cannot pickle a class defined +#: inside a function (``_host_class.``), and the SCM never imports +#: the class by this string anyway — the frozen exe hosts it directly. +_SERVICE_CLASS_STRING = "opcda_diagnozer.service.OpcdaDiagnozerService" + +#: How long SvcStop waits for the monitor thread before abandoning it +#: (spec §10.4: a thread stuck in a dead RPC channel is never joined). +_STOP_TIMEOUT_S = 30.0 + + +def service_config_path() -> str: + """The config path the installed service uses at startup. + + Recorded in the service's registry Parameters at install time; falls + back to the frozen-exe default (``config.yaml`` next to the exe) when + absent — a service starts with ``CWD = System32``, so the plain + relative default must never apply (see ``main._default_config_path``). + """ + if sys.platform != "win32": + return "" + try: + import win32serviceutil + + return win32serviceutil.GetServiceCustomOption( + SERVICE_NAME, "ConfigPath" + ) or "" + except Exception: # noqa: BLE001 — registry read failure -> default + return "" + + +def _log_info(message: str) -> None: + """Best-effort lifecycle note in the Windows Event Log (never raises).""" + if sys.platform != "win32": + return + try: + import servicemanager + + servicemanager.LogInfoMsg(message) + except Exception: # noqa: BLE001 — logging must never break the service + pass + + +def _host_class(): # noqa: ANN202 — declared lazily (Windows-only base) + """The ServiceFramework subclass, defined once on Windows only.""" + import win32service + import win32serviceutil + + class OpcdaDiagnozerService(win32serviceutil.ServiceFramework): + """Windows service host: monitor thread + clean SCM stop semantics.""" + + _svc_name_ = SERVICE_NAME + _svc_display_name_ = SERVICE_DISPLAY_NAME + _svc_description_ = SERVICE_DESCRIPTION + + def __init__(self, args: Sequence[str]) -> None: + win32serviceutil.ServiceFramework.__init__(self, args) + self._stop_event = threading.Event() + + def SvcStop(self) -> None: + """SCM wants a stop: report STOP_PENDING, ask the loop to finish. + + The heavy lifting (join-with-timeout, abandon on hang) happens at + the end of :meth:`SvcDoRun` so the SCM sees STOP_PENDING for the + whole shutdown (spec §6.1: clean stop handling). + """ + self.ReportServiceStatus( + win32service.SERVICE_STOP_PENDING, waitHint=int(_STOP_TIMEOUT_S * 1000) + ) + self._stop_event.set() + + def SvcDoRun(self) -> None: + """Run the monitoring loop on its own thread; never let SCM + observe START_PENDING forever if startup fails. + + The COM client itself is born on the poller's own worker thread + (spec §10.3) — this monitor thread is plain Python, no COM. + """ + import servicemanager + + servicemanager.LogMsg( + servicemanager.EVENTLOG_INFORMATION_TYPE, + servicemanager.PYS_SERVICE_STARTED, + (self._svc_name_, ""), + ) + try: + self._run_monitor() + finally: + servicemanager.LogMsg( + servicemanager.EVENTLOG_INFORMATION_TYPE, + servicemanager.PYS_SERVICE_STOPPED, + (self._svc_name_, ""), + ) + + def _run_monitor(self) -> None: + """Build the poller, run it on a thread, join with timeout. + + ``run_forever`` blocks until ``request_stop`` — but only between + polls; a poll can sit in a dead RPC far longer. If the join + times out the thread is **abandoned** (daemon: it cannot stop + the process from exiting) exactly like the poller's own hard- + hang policy (spec §10.4). + """ + from opcda_diagnozer.main import _build_reporters + from opcda_diagnozer.monitor.poller import Poller + from opcda_diagnozer.opc.automation import AutomationOpcClient + + config_path = ( + win32serviceutil.GetServiceCustomOption(self, "ConfigPath") + or None + ) + try: + config = load_config(config_path) + except ConfigError as exc: + _log_info( + f"{SERVICE_NAME}: startup failed - invalid config " + f"({config_path}): {exc}" + ) + return + + client_factory = lambda: AutomationOpcClient( # noqa: E731 — local, small + server=config.opc.server, + host=config.opc.host, + connect_timeout_s=config.opc.connect_timeout_s, + ) + reporters = _build_reporters(config) + poller = Poller(client_factory, config, reporters.emit) + + # The SCM stop path only sets our event; translate it to the + # poller's stop flag as soon as it fires. + watcher = threading.Thread( + target=lambda: (self._stop_event.wait(), poller.request_stop()), + daemon=True, + ) + watcher.start() + + monitor = threading.Thread( + target=lambda: self._guarded(poller), daemon=True + ) + monitor.start() + monitor.join(timeout=_STOP_TIMEOUT_S) + if monitor.is_alive(): + _log_info( + f"{SERVICE_NAME}: monitor thread did not stop within " + f"{_STOP_TIMEOUT_S:.0f} s (hung COM poll?) - abandoning " + "it and stopping the service process" + ) + reporters.close() + + def _guarded(self, poller) -> None: # noqa: ANN001 + """Run the poll loop; a crash is logged, never raised to SCM.""" + try: + poller.run_forever() + except Exception as exc: # noqa: BLE001 — log, keep the process sane + _log_info( + f"{SERVICE_NAME}: monitor thread crashed: " + f"{type(exc).__name__}: {exc}" + ) + + return OpcdaDiagnozerService + + +def get_service_class(): + """The service class (Windows only); ``None`` elsewhere.""" + if sys.platform != "win32": + return None + return _host_class() + + +# --------------------------------------------------------------------------- # +# SCM management (the install/uninstall/start/stop/status verbs, plan §6.2/6.3) +# --------------------------------------------------------------------------- # + +def failure_actions(restart_delay_ms: int = 5000, reset_period_s: int = 86400): + """The pywin32 ``SERVICE_FAILURE_ACTIONS`` dict for SCM recovery. + + Restart the service process 5 s after the first and second failure + (spec §10.1: "SCM recovery: restart on failure (first/second failure)"); + the failure counter resets after a day of clean running. The dict shape + is verified against pywin32's SWIG parser — + ``{'ResetPeriod': int, 'RebootMsg': str, 'Command': str, + 'Actions': ((SC_ACTION_RESTART, delay_ms), ...)}``. + """ + import win32service + + return { + "ResetPeriod": reset_period_s, + "RebootMsg": "", + "Command": "", + "Actions": ( + (win32service.SC_ACTION_RESTART, restart_delay_ms), + (win32service.SC_ACTION_RESTART, restart_delay_ms), + ), + } + + +def install_service(config_path: str, account: str | None = None, + password: str | None = None) -> None: + """Register the service with the SCM (auto-start + recovery). + + The exe **itself** is the service host (frozen deployment, SPEC §6.4): + the SCM starts this exe with the recorded ``exeArgs="service"`` + (CWD = System32), ``main`` recognizes that exact argv shape and runs + the SCM dispatcher. Auto-start per spec §10.1; SCM restart-on-failure + per §10.1/§6.3. The config path is recorded in the service's registry + Parameters so startup never depends on the service CWD. + """ + import win32service + import win32serviceutil + + exe_name = getattr(sys, "executable", None) + if exe_name is None: # pragma: no cover — sys.executable always exists + raise RuntimeError("cannot determine the executable path") + win32serviceutil.InstallService( + _SERVICE_CLASS_STRING, + SERVICE_NAME, + SERVICE_DISPLAY_NAME, + startType=win32service.SERVICE_AUTO_START, + exeName=exe_name, + exeArgs="service", + userName=account, + password=password, + description=SERVICE_DESCRIPTION, + ) + win32serviceutil.SetServiceCustomOption( + SERVICE_NAME, "ConfigPath", str(config_path) + ) + # SCM recovery (plan §6.3): restart on first/second failure. + import win32service as _ws + + hscm = _ws.OpenSCManager(None, None, _ws.SC_MANAGER_ALL_ACCESS) + try: + hs = win32serviceutil.SmartOpenService(hscm, SERVICE_NAME, _ws.SERVICE_ALL_ACCESS) + try: + _ws.ChangeServiceConfig2( + hs, _ws.SERVICE_CONFIG_FAILURE_ACTIONS, failure_actions() + ) + finally: + _ws.CloseServiceHandle(hs) + finally: + _ws.CloseServiceHandle(hscm) + # Register the Windows Event Log source with its message file so + # reporter events render with their text (report/eventlog.py defers + # this to install — AddSourceToRegistry needs admin). + import win32evtlogutil + + win32evtlogutil.AddSourceToRegistry(SERVICE_NAME) + + +def uninstall_service() -> None: + """Remove the service registration (leaves logs/config untouched).""" + import win32serviceutil + + win32serviceutil.RemoveService(SERVICE_NAME) + + +def start_service() -> None: + """Ask the SCM to start the service.""" + import win32serviceutil + + win32serviceutil.StartService(SERVICE_NAME, None) + + +def stop_service() -> None: + """Ask the SCM to stop the service.""" + import win32serviceutil + + win32serviceutil.StopService(SERVICE_NAME) + + +def service_status() -> str: + """One-line clear-text status (the SCM state name).""" + if sys.platform != "win32": + return "service hosting requires Windows" + import win32service + import win32serviceutil + + status = win32serviceutil.QueryServiceStatus(SERVICE_NAME) + return _STATUS_NAMES.get(status[1], f"unknown ({status[1]})") + + +def _init_status_names() -> dict[int, str]: + if sys.platform == "win32": + import win32service + + return { + win32service.SERVICE_STOPPED: "STOPPED", + win32service.SERVICE_START_PENDING: "START_PENDING", + win32service.SERVICE_STOP_PENDING: "STOP_PENDING", + win32service.SERVICE_RUNNING: "RUNNING", + win32service.SERVICE_CONTINUE_PENDING: "CONTINUE_PENDING", + win32service.SERVICE_PAUSE_PENDING: "PAUSE_PENDING", + win32service.SERVICE_PAUSED: "PAUSED", + } + return {} + + +_STATUS_NAMES = _init_status_names() + + +def handle_service_command_line(argv: Sequence[str]) -> int: + """SCM dispatch — the service process entry point. + + Called by ``main`` when the SCM starts the exe (the ``exeArgs="service"`` + recorded at install). This is the canonical frozen-service dispatch: + ``Initialize`` + ``PrepareToHostSingle`` + ``StartServiceCtrlDispatcher`` + — the dispatcher connects to the SCM that started us and blocks until + the service stops; only then does the process exit. Returns the exit + code (0 once the service has stopped; 1 when Windows is impossible + here — the platform guard). + """ + if sys.platform != "win32": + print("service hosting requires Windows", file=sys.stderr) + return 1 + cls = get_service_class() + if cls is None: # pragma: no cover — defensive, get_service_class guards + return 1 + import servicemanager + + servicemanager.Initialize() + servicemanager.PrepareToHostSingle(cls) + servicemanager.StartServiceCtrlDispatcher() + return 0 \ No newline at end of file diff --git a/tests/test_service.py b/tests/test_service.py new file mode 100644 index 0000000..9e0ce90 --- /dev/null +++ b/tests/test_service.py @@ -0,0 +1,317 @@ +"""Phase 3 Windows service tests — pure logic, no SCM needed. + +The actual SCM calls (install/start/stop against the real Service Control +Manager) are necessarily elevated-Windows-only; they are verified live in a +documented manual pass. What is testable anywhere: + +- the SCM dispatch detection (``_scm_started_us``) — the argv/CWD shape the + SCM starts the exe with, and the shapes a human CLI run produces; +- the failure-actions dict passed to ``ChangeServiceConfig2`` — its exact + pywin32 parse requirements (verified against the SWIG parser); +- the service class identity (name, display name, description) and the + registry class string install records; +- the CLI service verbs against injected failures: actionable messages, + exit codes (2 for environment errors), install's config validation; +- the service verbs' non-Windows guard. +""" + +import sys +from pathlib import Path +from unittest import mock + +import pytest + +import opcda_diagnozer.main as main_mod +import opcda_diagnozer.service as svc +from opcda_diagnozer.config import ConfigError + + +# --------------------------------------------------------------------------- # +# SCM dispatch detection +# --------------------------------------------------------------------------- # + +def _scm_environment(monkeypatch: pytest.MonkeyPatch) -> None: + """The environment the SCM gives a service process: CWD = System32.""" + monkeypatch.setattr(main_mod.sys, "platform", "win32") + monkeypatch.chdir("C:/Windows/System32") + monkeypatch.setenv("SystemRoot", "C:/Windows") + + +def test_scm_detection_the_registered_shape(monkeypatch: pytest.MonkeyPatch) -> None: + """argv == [exe, 'service'] with CWD System32 => SCM dispatch.""" + _scm_environment(monkeypatch) + monkeypatch.setattr(main_mod.sys, "argv", [r"C:\diag\opcda-diagnozer.exe", "service"]) + assert main_mod._scm_started_us() is True + + +def test_scm_detection_rejects_plain_cli(monkeypatch: pytest.MonkeyPatch) -> None: + """A human verb from System32 with one arg must NOT dispatch.""" + _scm_environment(monkeypatch) + monkeypatch.setattr(main_mod.sys, "argv", [r"C:\diag\opcda-diagnozer.exe", "status"]) + assert main_mod._scm_started_us() is False + + +def test_scm_detection_rejects_normal_cwd( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """'service' as the verb from any other CWD is a CLI call (unknown verb).""" + monkeypatch.setattr(main_mod.sys, "platform", "win32") + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(main_mod.sys, "argv", [r"C:\diag\opcda-diagnozer.exe", "service"]) + assert main_mod._scm_started_us() is False + + +@pytest.mark.skipif(sys.platform != "win32", reason="SCM detection is Windows-only") +def test_scm_detection_real_environment() -> None: + """The check never fires under pytest (a normal CWD + rich argv).""" + monkeypatch = pytest.MonkeyPatch() + monkeypatch.setattr(sys, "argv", [sys.executable, "pytest"]) + assert main_mod._scm_started_us() is False + + +def test_main_dispatches_to_service_host(monkeypatch: pytest.MonkeyPatch) -> None: + """When the SCM started us, main hands over without parsing the CLI.""" + _scm_environment(monkeypatch) + monkeypatch.setattr(main_mod.sys, "argv", [r"C:\diag\opcda-diagnozer.exe", "service"]) + dispatched: list[list[str]] = [] + + def _fake_host(argv): + dispatched.append(list(argv)) + return 0 + + monkeypatch.setattr( + "opcda_diagnozer.service.handle_service_command_line", _fake_host + ) + assert main_mod.main() == 0 + assert dispatched == [[r"C:\diag\opcda-diagnozer.exe", "service"]] + + +def test_main_with_explicit_argv_is_always_cli( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """main(argv=[...]) (tests, console_script plumbing) never SCM-dispatches.""" + _scm_environment(monkeypatch) + good = tmp_path / "config.yaml" + good.write_text( + "opc: {server: Fake.Server.1}\ntags: [{name: T.Counter}]\n", + encoding="utf-8", + ) + called = [] + monkeypatch.setattr( + "opcda_diagnozer.service.handle_service_command_line", + lambda argv: called.append(argv) or 1, + ) + # an explicit argv bypasses the SCM check entirely + assert main_mod.main(["check-config", "--config", str(good)]) == 0 + assert called == [] # never dispatched + + +# --------------------------------------------------------------------------- # +# failure actions (SCM recovery, plan §6.3) +# --------------------------------------------------------------------------- # + +@pytest.mark.skipif(sys.platform != "win32", reason="pywin32 SC_ACTION constants") +class TestFailureActions: + def test_shape_matches_pywin32_parser(self) -> None: + """The dict must carry every key the SWIG parser requires. + + Verified live: the parser rejects partial dicts with + "must be a dictionary containing {'ResetPeriod':int,'RebootMsg': + unicode,'Command':unicode,'Actions':sequence of 2 tuples(int,int)". + """ + import win32service + + fa = svc.failure_actions() + assert set(fa) == {"ResetPeriod", "RebootMsg", "Command", "Actions"} + assert isinstance(fa["ResetPeriod"], int) + assert isinstance(fa["RebootMsg"], str) + assert isinstance(fa["Command"], str) + actions = fa["Actions"] + for action in actions: + assert isinstance(action, tuple) + assert len(action) == 2 + assert all(isinstance(v, int) for v in action) + # first AND second failure restart the service (spec §10.1) + assert [a[0] for a in actions] == [ + win32service.SC_ACTION_RESTART, + win32service.SC_ACTION_RESTART, + ] + + def test_restart_delay_is_configurable(self) -> None: + fa = svc.failure_actions(restart_delay_ms=30_000) + assert all(delay == 30_000 for _, delay in fa["Actions"]) + + def test_parses_through_change_service_config2(self, tmp_path: Path) -> None: + """End-to-end: the exact dict install passes must survive the parser. + + Parse success is proven by the handle error (invalid handle — a + parse failure would raise TypeError naming the expected dict). + """ + import pywintypes + import win32service + + try: + win32service.ChangeServiceConfig2( + None, + win32service.SERVICE_CONFIG_FAILURE_ACTIONS, + svc.failure_actions(), + ) + except TypeError as exc: + pytest.fail(f"failure-actions dict does not parse: {exc}") + except pywintypes.error as exc: + assert exc.winerror == 6 # ERROR_INVALID_HANDLE: parse OK + + +# --------------------------------------------------------------------------- # +# service class identity + install payload +# --------------------------------------------------------------------------- # + +@pytest.mark.skipif(sys.platform != "win32", reason="ServiceFramework is Windows-only") +class TestServiceClass: + def test_identity(self) -> None: + cls = svc.get_service_class() + assert cls is not None + assert cls._svc_name_ == svc.SERVICE_NAME == "OPCDA-Diagnozer" + assert cls._svc_display_name_ == "OPC DA Diagnozer" + assert "read-only" in cls._svc_description_.lower() + + def test_class_string_is_importable_shape(self) -> None: + """The registry class string must be module.Class of THIS module.""" + assert svc._SERVICE_CLASS_STRING == ( + "opcda_diagnozer.service.OpcdaDiagnozerService" + ) + + def test_stop_timeout_exceeds_poll_slice(self) -> None: + """The SCM stop wait must be generous vs. the poller's stop latency.""" + from opcda_diagnozer.monitor.poller import _WATCH_SLICE_S + + assert svc._STOP_TIMEOUT_S > _WATCH_SLICE_S * 10 + + +# --------------------------------------------------------------------------- # +# CLI service verbs (injected SCM functions) +# --------------------------------------------------------------------------- # + +class _ScmError(Exception): + """win32service.error stand-in with the attributes the CLI reads.""" + + def __init__(self, winerror: int, strerror: str) -> None: + super().__init__(strerror) + self.winerror = winerror + self.strerror = strerror + + +class TestServiceVerbs: + def test_status_prints_state(self, capsys) -> None: + monkeypatch = pytest.MonkeyPatch() + monkeypatch.setattr(svc, "service_status", lambda: "STOPPED") + try: + code = main_mod._cmd_service_verb("status") + finally: + monkeypatch.undo() + out = capsys.readouterr().out + assert code == 0 + assert "OPCDA-Diagnozer" in out and "STOPPED" in out + + def test_install_validates_config_first(self, tmp_path: Path, capsys) -> None: + """A broken config must abort install BEFORE touching the SCM.""" + bad = tmp_path / "bad.yaml" + bad.write_text("opc: {server: S}\ntags: []\ndevices: []\n", encoding="utf-8") + installed = [] + + def _fail(*args, **kwargs): # pragma: no cover — must not be reached + installed.append(args) + + monkeypatch = pytest.MonkeyPatch() + monkeypatch.setattr(svc, "install_service", _fail) + try: + code = main_mod._cmd_service_verb("install", config_path=str(bad)) + finally: + monkeypatch.undo() + assert code == 2 + assert installed == [] # SCM never touched + assert "invalid" in capsys.readouterr().err + + def test_install_happy_path(self, tmp_path: Path, capsys) -> None: + good = tmp_path / "config.yaml" + good.write_text( + "opc: {server: Fake.Server.1}\ntags: [{name: T.Counter}]\n", + encoding="utf-8", + ) + calls = {} + + def _install(config_path, account=None, password=None): + calls["config"] = config_path + calls["account"] = account + + monkeypatch = pytest.MonkeyPatch() + monkeypatch.setattr(svc, "install_service", _install) + try: + code = main_mod._cmd_service_verb("install", config_path=str(good)) + finally: + monkeypatch.undo() + out = capsys.readouterr().out + assert code == 0 + assert calls["config"] == str(good) + assert "auto-start" in out + + def test_access_denied_gets_the_elevation_hint(self, capsys) -> None: + exc = _ScmError(5, "Access is denied.") + out = main_mod._service_verb_error("install", exc) + err = capsys.readouterr().err + assert out == 2 + assert "elevated" in err + assert "Access is denied." in err + + def test_not_installed_gets_the_install_hint(self, capsys) -> None: + exc = _ScmError(1060, "The specified service does not exist as an installed service.") + out = main_mod._service_verb_error("start", exc) + err = capsys.readouterr().err + assert out == 2 + assert "not installed" in err + + def test_not_active_stop_hint(self, capsys) -> None: + exc = _ScmError(1062, "The service has not been started.") + out = main_mod._service_verb_error("stop", exc) + err = capsys.readouterr().err + assert out == 2 + assert "not running" in err + + def test_unknown_winerror_still_names_the_code(self, capsys) -> None: + exc = _ScmError(1053, "The service did not respond to the start request.") + out = main_mod._service_verb_error("start", exc) + err = capsys.readouterr().err + assert out == 2 + assert "1053" in err + assert "net helpmsg" in err + + def test_plain_exception_without_winerror(self, capsys) -> None: + out = main_mod._service_verb_error("uninstall", ConfigError("registry offline")) + err = capsys.readouterr().err + assert out == 2 + assert "registry offline" in err + + def test_verb_dispatch_via_main(self, monkeypatch: pytest.MonkeyPatch, capsys) -> None: + """The verbs must be reachable through the real parser.""" + monkeypatch.setattr(svc, "service_status", lambda: "RUNNING") + monkeypatch.setattr(svc, "install_service", lambda *a, **k: None) + monkeypatch.setattr(svc, "uninstall_service", lambda: None) + monkeypatch.setattr(svc, "start_service", lambda: None) + monkeypatch.setattr(svc, "stop_service", lambda: None) + assert main_mod.main(["status"]) == 0 + assert "RUNNING" in capsys.readouterr().out + + +# --------------------------------------------------------------------------- # +# non-Windows guard +# --------------------------------------------------------------------------- # + +def test_service_module_imports_everywhere() -> None: + """The module is importable without pywin32 (guarded imports, plan §9).""" + assert svc.SERVICE_NAME == "OPCDA-Diagnozer" + assert callable(svc.handle_service_command_line) + + +@pytest.mark.skipif(sys.platform == "win32", reason="guard test for other platforms") +def test_get_service_class_none_off_windows() -> None: + assert svc.get_service_class() is None \ No newline at end of file