🚧 Implement service stub
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+149
-21
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)."
|
||||
)
|
||||
|
||||
#: 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:
|
||||
raise NotImplementedError(
|
||||
"Phase 3: Windows service host (docs/IMPLEMENTATION_PLAN.md §6)"
|
||||
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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user