🚧 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
+150 -22
View File
@@ -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
+347 -7
View File
@@ -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)"
)
#: 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.<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