🚧 Implement service stub

This commit is contained in:
Jonas Bengtsson
2026-10-08 11:01:03 +02:00
parent ca0bb0d048
commit e5675ec559
6 changed files with 936 additions and 51 deletions
+31
View File
@@ -6,6 +6,37 @@ bump per [SemVer](https://semver.org/). Dates are YYYY-MM-DD.
## [Unreleased] ## [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 ### Added — Phase 2 reporters
- Rotating JSONL evidence log: one JSON event per line, config-digest header - Rotating JSONL evidence log: one JSON event per line, config-digest header
for provenance, size rotation with backups retained. for provenance, size rotation with backups retained.
+64 -12
View File
@@ -11,9 +11,10 @@ and flapping quality — as structured events (JSONL logs, Windows Event Log, op
HTTP webhook). HTTP webhook).
> **Status:** in active development — discovery, browsing (`list-items`), one-shot > **Status:** in active development — discovery, browsing (`list-items`), one-shot
> reads (`read`/`probe`) and the exe build are **live and field-verified** against a > reads (`read`/`probe`), the continuous monitoring loop (`run`), all reporters,
> real server; the continuous monitoring loop, reporters and Windows service host are > the exe build and the Windows service host are **live and field-verified**
> next. See the [TODO](TODO.md) for current status and the > (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 > [implementation plan](docs/IMPLEMENTATION_PLAN.md) for the phases; read the
> [specification](docs/SPECIFICATION.md) for detection rules and behavior. > [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). 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 - Local or remote (DCOM) connections to any OPC DA server
- Server browsing: `list-servers` (discovery) and `list-items` - Server browsing: `list-servers` (discovery) and `list-items`
(address-space browse: full tree / branches / tags) — live now (address-space browse: full tree / branches / tags) — live now
@@ -83,8 +84,16 @@ opcda-diagnozer probe
# foreground run (console) — continuous monitoring; Ctrl+C stops # foreground run (console) — continuous monitoring; Ctrl+C stops
opcda-diagnozer run opcda-diagnozer run
# install as Windows service (dedicated account required for DCOM auth) # install as a Windows service (auto-start + SCM crash recovery; admin shell)
opcda-diagnozer install --account DOMAIN\svc-opcdiag # 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 `list-servers` uses two discovery mechanisms (`--source`): the OPC DA Automation
Wrapper's `GetOPCServers` over COM (works remotely via DCOM; needs Launch+Activation 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 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 scriptable health check. `--source cache` reads the server cache instead of
forcing a device round-trip. 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`) ### Configuration example (`config.example.yaml`)
```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 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 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 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 `check-config`, `list-servers --source registry`) after building. It also
installs PyInstaller into the venv automatically if it is missing. 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 `CWD = C:\Windows\System32`, so the relative default would silently point
there; the exe-dir default keeps the deployment layout simple there; the exe-dir default keeps the deployment layout simple
(`C:\Program Files\OPCDA-Diagnozer\{opcda-diagnozer.exe, config.yaml}`). (`C:\Program Files\OPCDA-Diagnozer\{opcda-diagnozer.exe, config.yaml}`).
- The exe is a console application that hosts every CLI verb, including the - The exe is a console application that hosts every CLI verb **and the
future `run` / `probe` / service verbs. The Phase 3 Windows service Windows service**: `install` registers this same exe with the SCM, and
(`install` verb) will point `sc.exe` at this same exe with the service-host when the SCM starts it the process recognizes the fixed service-start
entry point. 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 - The Automation Wrapper DLL (`OPCAutomation.dll`) is **not** bundled — it is
a machine-installed COM component (registered in the registry), exactly like a machine-installed COM component (registered in the registry), exactly like
the OPC DA servers the tool connects to. Only Python code and its the OPC DA servers the tool connects to. Only Python code and its
+27 -10
View File
@@ -69,17 +69,34 @@ Legend: **[done]** complete & verified · **[partial]** some of it landed ·
(`report/base.py`): per-sink failure isolation, best-effort close. The (`report/base.py`): per-sink failure isolation, best-effort close. The
`run` verb wires console + JSONL + textlog + eventlog (+http when set). `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 - [done] 1. `service.py` — `ServiceFramework` host (monitor thread +
(`STOP_PENDING`, join-with-timeout, abandon hung COM thread). poller, `PYS_SERVICE_STARTED/STOPPED` lifecycle entries); clean stop
- [todo] 2. CLI verbs `install`/`uninstall`/`start`/`stop` (sc.exe semantics). (`STOP_PENDING`, 30 s join-with-timeout, abandon hung COM thread,
- [todo] 3. SCM recovery (`sc failure ... restart`), auto-start, service never joined — verified in-process via the pywin32 debug host against
account docs. the fake client: start → poll → stop in 0.1 s).
- [todo] 4. `%ProgramData%\OPCDA-Diagnozer\` config/log dir conventions - [done] 2. CLI verbs `install`/`uninstall`/`start`/`stop`/`status` —
(frozen exe already defaults there). actionable error mapping (elevation / not-installed / not-running
- [open] Frozen-exe SCM hosting: console exe must expose `ServiceMain` — one hints), install validates the config BEFORE touching the SCM, and
careful build pass (pywin32 `ServiceControlDispatcher`). 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 ## Phase 4 — Integration & chaos testing
+150 -22
View File
@@ -1,25 +1,30 @@
"""Command-line interface (IMPLEMENTATION_PLAN §6.2). """Command-line interface (IMPLEMENTATION_PLAN §6.2).
Verbs: ``run``, ``probe``, ``check-config``, ``list-items`` plus the service Verbs: ``run``, ``probe``, ``check-config``, ``list-items``, the service
verbs ``install`` / ``uninstall`` / ``start`` / ``stop``. Currently functional: verbs ``install`` / ``uninstall`` / ``start`` / ``stop`` / ``status``, and
``check-config``, ``list-servers`` (discovery), ``list-items`` (address-space the internal ``service`` verb — the SCM dispatch entry the install records
browsing: ``all`` / ``branches`` / ``tags``) and ``read`` / ``probe`` (one-shot in the service ImagePath (the exe hosts itself; SPEC §6.4).
tag reads); the remaining verbs land with Phases 1–3.
""" """
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import os
import sys import sys
import threading import threading
from collections.abc import Sequence from collections.abc import Sequence
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING
from opcda_diagnozer import __version__ from opcda_diagnozer import __version__
from opcda_diagnozer.config import ConfigError, load_config from opcda_diagnozer.config import ConfigError, load_config
from opcda_diagnozer.opc.base import OpcError from opcda_diagnozer.opc.base import OpcError
from opcda_diagnozer.opc.discovery import ServerInfo, list_servers 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" DEFAULT_CONFIG = "config.yaml"
@@ -35,12 +40,24 @@ def _default_config_path() -> str:
return str(Path(sys.executable).resolve().parent / DEFAULT_CONFIG) return str(Path(sys.executable).resolve().parent / DEFAULT_CONFIG)
return DEFAULT_CONFIG return DEFAULT_CONFIG
_NOT_IMPLEMENTED: dict[str, str] = {
"install": "Phase 3 (Windows service)", def _scm_started_us() -> bool:
"uninstall": "Phase 3 (Windows service)", """True when the SCM (not a human) launched this process.
"start": "Phase 3 (Windows service)",
"stop": "Phase 3 (Windows service)", 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: 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", 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) _add_config_option(p)
p.add_argument( p.add_argument(
"--account", "--account",
metavar="DOMAIN\\USER", 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 ( for verb, help_text in (
("uninstall", "remove the Windows service"), ("uninstall", "remove the Windows service registration (requires admin)"),
("start", "start the Windows service"), ("start", "start the Windows service (requires admin)"),
("stop", "stop the Windows service"), ("stop", "stop the Windows service (requires admin)"),
("status", "print the service state (works without admin)"),
): ):
sub.add_parser(verb, help=help_text) 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 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: def main(argv: Sequence[str] | None = None) -> int:
"""CLI entry point (console script + ``python -m opcda_diagnozer``).""" """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) args = _build_parser().parse_args(argv)
if args.verb == "check-config": if args.verb == "check-config":
return _cmd_check_config(args.config) return _cmd_check_config(args.config)
@@ -687,12 +813,14 @@ def main(argv: Sequence[str] | None = None) -> int:
) )
if args.verb == "probe": if args.verb == "probe":
return _cmd_probe(args.config, args.server, args.host, args.source) return _cmd_probe(args.config, args.server, args.host, args.source)
phase = _NOT_IMPLEMENTED[args.verb] if args.verb in ("install", "uninstall", "start", "stop", "status"):
print( return _cmd_service_verb(
f"{args.verb}: not implemented yet — planned for {phase} " args.verb,
"(see docs/IMPLEMENTATION_PLAN.md)", config_path=getattr(args, "config", None),
file=sys.stderr, account=getattr(args, "account", None),
) password=getattr(args, "password", None),
)
print(f"{args.verb}: not implemented yet", file=sys.stderr)
return 1 return 1
+347 -7
View File
@@ -2,18 +2,358 @@
IMPLEMENTATION_PLAN §6.1: the service spawns the COM worker thread and must 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 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 from __future__ import annotations
import sys
import threading
from collections.abc import Sequence from collections.abc import Sequence
from opcda_diagnozer.config import ConfigError, load_config
class OpcdaDiagnozerService: SERVICE_NAME = "OPCDA-Diagnozer"
"""pywin32 service wrapper — implemented in Phase 3.""" 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: #: The pywin32 class string recorded in the service registry. The class is
raise NotImplementedError( #: defined lazily (its pywin32 base is Windows-only), so this module-level
"Phase 3: Windows service host (docs/IMPLEMENTATION_PLAN.md §6)" #: constant carries the registration identity instead of pickle-derived
) #: introspection: ``GetServiceClassString`` cannot pickle a class defined
#: inside a function (``_host_class.<locals>``), 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
+317
View File
@@ -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