fix: persist and recover the Agent's real installation_id (heartbeat activation)
POST /v1/agents/enroll now returns installation_id; new installs persist it to agent.env immediately. Runtime recovery precedence: existing SQLite installation_identity -> configured INSTALLATION_ID -> authenticated GET /v1/agents/me/identity. Recovery fails closed on missing/malformed/ mismatched identity rather than inventing a UUID (which the Hub would then reject as a conflicting Installation on an already-enrolled Agent). An explicit AGENT_JOBS_ENABLED gate stops the service from claiming a job right after the first heartbeat. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -94,6 +94,8 @@ class Config:
|
||||
self.fiscal_mode = (env.get("FISCAL_MODE") or ("AUTO_APPLY" if requested else "DRY_RUN")).strip().upper()
|
||||
self.requested_writes = requested
|
||||
self.firebird_writes = requested and self.fiscal_mode == "AUTO_APPLY"
|
||||
jobs_enabled = (env.get("AGENT_JOBS_ENABLED", "1") or "1").strip().upper()
|
||||
self.agent_jobs_enabled = jobs_enabled not in ("0", "FALSE", "NO")
|
||||
self.poll_interval_seconds = float(env.get("POLL_INTERVAL_SECONDS", "5"))
|
||||
self.agent_version = env.get("AGENT_VERSION", "1.1.1")
|
||||
self.schema_fingerprint = env.get("SCHEMA_FINGERPRINT", REFERENCE_SCHEMA_V1)
|
||||
@@ -221,6 +223,27 @@ def execute_job(job, config, executor, state, env):
|
||||
return None, f"{type(error).__name__}: {error}"
|
||||
|
||||
|
||||
def resolve_installation_id(config, env, state, client):
|
||||
"""Recover and persist the Hub-bound identity; never invent one for an Agent."""
|
||||
persisted = state.stored_installation_id()
|
||||
configured = (env.get("INSTALLATION_ID") or "").strip() or None
|
||||
if persisted is not None:
|
||||
return state.installation_id(configured)
|
||||
if configured is not None:
|
||||
return state.installation_id(configured)
|
||||
|
||||
configured_agent_id = str(env.get("AGENT_ID") or config.agent_id or "").strip()
|
||||
if not configured_agent_id:
|
||||
raise RuntimeError("AGENT_ID is required to recover installation identity")
|
||||
identity = client.identity()
|
||||
if str(identity.get("agent_id") or "") != configured_agent_id:
|
||||
raise RuntimeError("authenticated identity does not match configured Agent")
|
||||
installation_id = identity.get("installation_id")
|
||||
if not installation_id:
|
||||
raise RuntimeError("authenticated identity response has no installation_id")
|
||||
return state.installation_id(str(installation_id))
|
||||
|
||||
|
||||
def run(config, env, *, max_iterations=None, stopper=None):
|
||||
"""`stopper` defaults to a signal-based GracefulStop (SIGINT/SIGTERM), which
|
||||
only works from the main thread. A host that manages its own lifecycle
|
||||
@@ -230,7 +253,7 @@ def run(config, env, *, max_iterations=None, stopper=None):
|
||||
executor = load_executor()
|
||||
state = AgentState(config.state_db)
|
||||
client = HubClient(config.hub_url, config.agent_token)
|
||||
installation_id = state.installation_id() if hasattr(state, "installation_id") else (config.agent_id or "legacy-installation")
|
||||
installation_id = resolve_installation_id(config, env, state, client)
|
||||
stopper = stopper if stopper is not None else GracefulStop()
|
||||
|
||||
backoff = 1
|
||||
@@ -266,6 +289,10 @@ def run(config, env, *, max_iterations=None, stopper=None):
|
||||
backoff = min(backoff * 2, _MAX_BACKOFF_SECONDS)
|
||||
continue
|
||||
|
||||
if not config.agent_jobs_enabled:
|
||||
time.sleep(config.poll_interval_seconds)
|
||||
continue
|
||||
|
||||
try:
|
||||
job = client.next_job()
|
||||
except Exception as error:
|
||||
|
||||
@@ -87,7 +87,15 @@ if win32serviceutil is not None:
|
||||
def main():
|
||||
if win32serviceutil is None:
|
||||
raise SystemExit("pywin32 is required: pip install pywin32")
|
||||
win32serviceutil.HandleCommandLine(GTIFiscalAgentService)
|
||||
if len(sys.argv) == 1:
|
||||
# SCM launches a service executable without CLI arguments. HandleCommandLine
|
||||
# requires a command (install/start/debug) and exits with usage when none
|
||||
# is present, which makes SCM report error 1053 for a frozen service host.
|
||||
servicemanager.Initialize()
|
||||
servicemanager.PrepareToHostSingle(GTIFiscalAgentService)
|
||||
servicemanager.StartServiceCtrlDispatcher()
|
||||
else:
|
||||
win32serviceutil.HandleCommandLine(GTIFiscalAgentService)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -13,6 +13,7 @@ class HubClient:
|
||||
def get_json(self, path):
|
||||
response = httpx.get(f"{self.url}{path}", headers=self._headers(), timeout=30)
|
||||
response.raise_for_status(); return response.json()
|
||||
def identity(self): return self.get_json("/v1/agents/me/identity")
|
||||
def health(self):
|
||||
response = httpx.get(f"{self.url}/health", timeout=10)
|
||||
return response.status_code == 200
|
||||
|
||||
@@ -9,12 +9,31 @@ class AgentState:
|
||||
self.connection.executescript("CREATE TABLE IF NOT EXISTS product_state(product_id TEXT PRIMARY KEY, source_fingerprint TEXT NOT NULL, dataset_version TEXT NOT NULL, engine_version TEXT NOT NULL, status TEXT NOT NULL, last_processed_at TEXT, last_result_hash TEXT, reason_code TEXT); CREATE TABLE IF NOT EXISTS outbox(product_id TEXT PRIMARY KEY, source_fingerprint TEXT NOT NULL, status TEXT NOT NULL, retries INTEGER NOT NULL DEFAULT 0); CREATE TABLE IF NOT EXISTS ipi_write_log(product_id TEXT NOT NULL, source_fingerprint TEXT NOT NULL, decision_status TEXT, ipi_status TEXT, write_status TEXT NOT NULL, before_value TEXT, proposed_value TEXT, result TEXT, reason_code TEXT, timestamp TEXT NOT NULL);")
|
||||
self.connection.execute("CREATE TABLE IF NOT EXISTS installation_identity (id INTEGER PRIMARY KEY CHECK(id=1), installation_id TEXT NOT NULL)")
|
||||
self.connection.commit()
|
||||
def installation_id(self):
|
||||
def stored_installation_id(self):
|
||||
row = self.connection.execute("SELECT installation_id FROM installation_identity WHERE id=1").fetchone()
|
||||
if row:
|
||||
return row["installation_id"]
|
||||
value = str(uuid.uuid4())
|
||||
self.connection.execute("INSERT INTO installation_identity(id, installation_id) VALUES(1, ?)", (value,)); self.connection.commit()
|
||||
if row is None:
|
||||
return None
|
||||
try:
|
||||
return str(uuid.UUID(row["installation_id"]))
|
||||
except (TypeError, ValueError, AttributeError) as error:
|
||||
raise ValueError("stored installation_id is not a valid UUID") from error
|
||||
def installation_id(self, candidate=None):
|
||||
existing = self.stored_installation_id()
|
||||
if existing:
|
||||
if candidate is not None and str(uuid.UUID(str(candidate))) != existing:
|
||||
raise ValueError("configured installation_id conflicts with persisted identity")
|
||||
return existing
|
||||
if candidate is None or not str(candidate).strip():
|
||||
raise ValueError("installation_id is required; refusing to generate a new identity")
|
||||
try:
|
||||
value = str(uuid.UUID(str(candidate)))
|
||||
except (TypeError, ValueError, AttributeError) as error:
|
||||
raise ValueError("installation_id must be a valid UUID") from error
|
||||
self.connection.execute("INSERT OR IGNORE INTO installation_identity(id, installation_id) VALUES(1, ?)", (value,))
|
||||
self.connection.commit()
|
||||
row = self.connection.execute("SELECT installation_id FROM installation_identity WHERE id=1").fetchone()
|
||||
if row is None or str(uuid.UUID(row["installation_id"])) != value:
|
||||
raise ValueError("persisted installation_id conflicts with candidate")
|
||||
return value
|
||||
def enqueue(self, product_id, source_fingerprint, dataset_version, engine_version):
|
||||
row = self.connection.execute("SELECT * FROM product_state WHERE product_id=?", (product_id,)).fetchone()
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import httpx
|
||||
|
||||
from gti_fiscal_agent.client import HubClient
|
||||
|
||||
|
||||
def test_identity_requests_authenticated_identity_only(monkeypatch):
|
||||
calls = {}
|
||||
payload = {
|
||||
"agent_id": 2,
|
||||
"installation_id": "00000000-0000-4000-8000-000000000222",
|
||||
"hostname": "DESKTOP-B0HL02I",
|
||||
"version": "1.1.1",
|
||||
"status": "OFFLINE",
|
||||
}
|
||||
|
||||
class Response:
|
||||
status_code = 200
|
||||
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
def json(self):
|
||||
return payload
|
||||
|
||||
def fake_get(url, *, headers, timeout):
|
||||
calls.update(url=url, headers=headers, timeout=timeout)
|
||||
return Response()
|
||||
|
||||
monkeypatch.setattr(httpx, "get", fake_get)
|
||||
client = HubClient("https://hub.example", "test-agent-token")
|
||||
assert client.identity() == payload
|
||||
|
||||
assert calls["url"] == "https://hub.example/v1/agents/me/identity"
|
||||
assert calls["headers"] == {"Authorization": "Bearer test-agent-token"}
|
||||
@@ -22,6 +22,19 @@ service = load_module()
|
||||
class _FakeState:
|
||||
def __init__(self):
|
||||
self.logged = []
|
||||
self.identity = None
|
||||
|
||||
def stored_installation_id(self):
|
||||
return self.identity
|
||||
|
||||
def installation_id(self, candidate=None):
|
||||
if self.identity and candidate and self.identity != candidate:
|
||||
raise ValueError("configured installation_id conflicts with persisted identity")
|
||||
if self.identity is None:
|
||||
if not candidate:
|
||||
raise ValueError("installation_id is required")
|
||||
self.identity = candidate
|
||||
return self.identity
|
||||
|
||||
def log_ipi_write(self, *args):
|
||||
self.logged.append(args)
|
||||
@@ -61,7 +74,7 @@ def _config(**overrides):
|
||||
base = dict(
|
||||
hub_url="http://localhost:8000", agent_id="a1", agent_token="tok", company_id="c1", company_uf="BA",
|
||||
database_path=AUTHORIZED, firebird_writes=False, poll_interval_seconds=0.01,
|
||||
agent_version="0.1.0", schema_fingerprint="fp", state_db=":memory:",
|
||||
agent_version="0.1.0", schema_fingerprint="fp", state_db=":memory:", agent_jobs_enabled=True,
|
||||
authorized_write_database_path=None, expected_schema_fingerprint="fp",
|
||||
)
|
||||
base.update(overrides)
|
||||
@@ -74,6 +87,13 @@ def test_config_defaults_from_env():
|
||||
assert config.firebird_writes is False
|
||||
assert config.hub_url == "http://localhost:8000"
|
||||
assert config.poll_interval_seconds == 5.0
|
||||
assert config.agent_jobs_enabled is True
|
||||
|
||||
|
||||
def test_config_can_disable_agent_job_polling():
|
||||
config = service.Config({"FIREBIRD_DATABASE_PATH": AUTHORIZED, "AGENT_JOBS_ENABLED": "0"})
|
||||
|
||||
assert config.agent_jobs_enabled is False
|
||||
|
||||
|
||||
def test_config_reads_gti_hub_url_and_writes_flag():
|
||||
@@ -83,6 +103,54 @@ def test_config_reads_gti_hub_url_and_writes_flag():
|
||||
assert config.firebird_writes is True
|
||||
|
||||
|
||||
def test_identity_recovery_persists_existing_hub_installation_id(tmp_path):
|
||||
config = _config(agent_id="2")
|
||||
state = service.AgentState(tmp_path / "agent.db")
|
||||
installation_id = "00000000-0000-4000-8000-000000000222"
|
||||
|
||||
class FakeIdentityClient:
|
||||
calls = 0
|
||||
|
||||
def identity(self):
|
||||
self.calls += 1
|
||||
return {"agent_id": 2, "installation_id": installation_id}
|
||||
|
||||
client = FakeIdentityClient()
|
||||
recovered = service.resolve_installation_id(config, {}, state, client)
|
||||
|
||||
assert recovered == installation_id
|
||||
assert state.installation_id() == installation_id
|
||||
assert client.calls == 1
|
||||
|
||||
restarted = service.resolve_installation_id(config, {}, state, client)
|
||||
assert restarted == installation_id
|
||||
assert client.calls == 1
|
||||
|
||||
|
||||
def test_identity_recovery_fails_closed_for_another_agent_or_missing_id(tmp_path):
|
||||
config = _config(agent_id="2")
|
||||
|
||||
class FakeIdentityClient:
|
||||
def __init__(self, payload):
|
||||
self.payload = payload
|
||||
def identity(self):
|
||||
return self.payload
|
||||
|
||||
state = service.AgentState(tmp_path / "agent.db")
|
||||
with pytest.raises(RuntimeError, match="identity does not match configured Agent"):
|
||||
service.resolve_installation_id(
|
||||
config,
|
||||
{},
|
||||
state,
|
||||
FakeIdentityClient({"agent_id": 3, "installation_id": "00000000-0000-4000-8000-000000000333"}),
|
||||
)
|
||||
assert state.connection.execute("SELECT COUNT(*) FROM installation_identity").fetchone()[0] == 0
|
||||
|
||||
with pytest.raises(RuntimeError, match="identity response has no installation_id"):
|
||||
service.resolve_installation_id(config, {}, state, FakeIdentityClient({"agent_id": 2}))
|
||||
assert state.connection.execute("SELECT COUNT(*) FROM installation_identity").fetchone()[0] == 0
|
||||
|
||||
|
||||
def test_path_guard_fails_closed_before_any_write(monkeypatch):
|
||||
config = _config(firebird_writes=True, database_path=r"D:\PRODUCAO\ERP.FDB")
|
||||
executor = _FakeExecutor(path_guard_ok=False)
|
||||
@@ -226,6 +294,57 @@ class _ExternalStopFlag:
|
||||
self.stop = False
|
||||
|
||||
|
||||
def test_run_sends_heartbeat_but_never_polls_when_jobs_disabled(monkeypatch, tmp_path):
|
||||
stopper = _ExternalStopFlag()
|
||||
fake_hub = _FakeHubForLoop()
|
||||
installation_id = "00000000-0000-4000-8000-000000000222"
|
||||
calls = {"identity": 0, "next_job": 0, "heartbeat": 0, "heartbeat_installation_id": None}
|
||||
|
||||
def identity():
|
||||
calls["identity"] += 1
|
||||
return {"agent_id": 2, "installation_id": installation_id}
|
||||
|
||||
def heartbeat_metadata(payload):
|
||||
calls["heartbeat"] += 1
|
||||
calls["heartbeat_installation_id"] = payload["installation_id"]
|
||||
stopper.stop = True
|
||||
return {"agent_id": 2}
|
||||
|
||||
def forbidden_next_job():
|
||||
calls["next_job"] += 1
|
||||
raise AssertionError("jobs are disabled; next_job must not be called")
|
||||
|
||||
fake_hub.identity = identity
|
||||
fake_hub.heartbeat_metadata = heartbeat_metadata
|
||||
fake_hub.next_job = forbidden_next_job
|
||||
monkeypatch.setattr(service, "HubClient", lambda *a, **k: fake_hub)
|
||||
monkeypatch.setattr(service, "load_executor", lambda: _FakeExecutor())
|
||||
monkeypatch.setattr(service.time, "sleep", lambda _seconds: None)
|
||||
|
||||
config = _config(
|
||||
agent_id="2",
|
||||
agent_jobs_enabled=False,
|
||||
poll_interval_seconds=0,
|
||||
state_db=str(tmp_path / "agent.db"),
|
||||
)
|
||||
env = {
|
||||
"AGENT_ID": "2",
|
||||
"FIREBIRD_DATABASE_PATH": AUTHORIZED,
|
||||
"FIREBIRD_WRITES": "0",
|
||||
"FISCAL_MODE": "DRY_RUN",
|
||||
}
|
||||
|
||||
service.run(config, env, stopper=stopper)
|
||||
|
||||
assert calls == {
|
||||
"identity": 1,
|
||||
"next_job": 0,
|
||||
"heartbeat": 1,
|
||||
"heartbeat_installation_id": installation_id,
|
||||
}
|
||||
assert service.AgentState(config.state_db).stored_installation_id() == installation_id
|
||||
|
||||
|
||||
def test_run_honors_an_externally_supplied_stopper(monkeypatch):
|
||||
# This is exactly the pattern the Windows Service wrapper uses: it never
|
||||
# constructs GracefulStop() (which would crash off the main thread), it
|
||||
@@ -248,7 +367,7 @@ def test_run_honors_an_externally_supplied_stopper(monkeypatch):
|
||||
stopper.stop = True
|
||||
|
||||
monkeypatch.setattr(service.time, "sleep", counting_sleep)
|
||||
service.run(config, {}, stopper=stopper) # no max_iterations — only the external flag can stop it
|
||||
service.run(config, {"AGENT_ID": "2", "INSTALLATION_ID": "00000000-0000-4000-8000-000000000222"}, stopper=stopper) # no max_iterations — only the external flag can stop it
|
||||
assert stopper.stop is True
|
||||
assert calls["n"] >= 3
|
||||
|
||||
@@ -264,7 +383,7 @@ def test_run_survives_hub_down_then_processes_one_job_and_stops(monkeypatch):
|
||||
monkeypatch.setattr(service.time, "sleep", lambda seconds: None)
|
||||
|
||||
# first heartbeat call raises -> backoff branch; second call onward succeeds.
|
||||
service.run(config, {}, max_iterations=4)
|
||||
service.run(config, {"AGENT_ID": "2", "INSTALLATION_ID": "00000000-0000-4000-8000-000000000222"}, max_iterations=4)
|
||||
|
||||
assert fake_hub.heartbeat_calls >= 2
|
||||
assert len(fake_hub.completed) == 1
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import pytest
|
||||
|
||||
from gti_fiscal_agent.state import AgentState
|
||||
|
||||
|
||||
@@ -18,3 +20,37 @@ def test_ipi_write_log_records_per_product_audit(tmp_path):
|
||||
assert log[0]["write_status"] == "SUCCEEDED"
|
||||
assert log[0]["before_value"] is None
|
||||
assert log[0]["proposed_value"] == "3.25"
|
||||
|
||||
|
||||
def test_installation_identity_requires_an_existing_or_explicit_id(tmp_path):
|
||||
state = AgentState(tmp_path / "agent.db")
|
||||
|
||||
assert state.stored_installation_id() is None
|
||||
with pytest.raises(ValueError, match="installation_id is required"):
|
||||
state.installation_id()
|
||||
|
||||
assert state.connection.execute("SELECT COUNT(*) FROM installation_identity").fetchone()[0] == 0
|
||||
|
||||
|
||||
def test_installation_identity_persists_and_reuses_explicit_id(tmp_path):
|
||||
db_path = tmp_path / "agent.db"
|
||||
state = AgentState(db_path)
|
||||
existing_id = "00000000-0000-4000-8000-000000000222"
|
||||
|
||||
assert state.installation_id(existing_id) == existing_id
|
||||
assert state.installation_id() == existing_id
|
||||
assert state.stored_installation_id() == existing_id
|
||||
|
||||
restarted_state = AgentState(db_path)
|
||||
assert restarted_state.installation_id() == existing_id
|
||||
|
||||
|
||||
def test_installation_identity_rejects_conflicting_or_malformed_ids(tmp_path):
|
||||
state = AgentState(tmp_path / "agent.db")
|
||||
existing_id = "00000000-0000-4000-8000-000000000222"
|
||||
state.installation_id(existing_id)
|
||||
|
||||
with pytest.raises(ValueError, match="conflicts"):
|
||||
state.installation_id("00000000-0000-4000-8000-000000000223")
|
||||
with pytest.raises(ValueError, match="UUID"):
|
||||
state.installation_id("not-a-uuid")
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
|
||||
SERVICE_WRAPPER = Path(__file__).parents[1] / "service" / "gti_agent_windows_service.py"
|
||||
|
||||
|
||||
def load_service_wrapper():
|
||||
spec = importlib.util.spec_from_file_location("gti_agent_windows_service_test", SERVICE_WRAPPER)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_main_enters_scm_dispatcher_when_started_without_command(monkeypatch):
|
||||
service = load_service_wrapper()
|
||||
calls = []
|
||||
|
||||
class FakeServiceManager:
|
||||
def Initialize(self):
|
||||
calls.append("initialize")
|
||||
|
||||
def PrepareToHostSingle(self, service_class):
|
||||
calls.append(("prepare", service_class))
|
||||
|
||||
def StartServiceCtrlDispatcher(self):
|
||||
calls.append("dispatcher")
|
||||
|
||||
def forbidden_handle_command_line(service_class):
|
||||
raise AssertionError("SCM start must not be parsed as a CLI command")
|
||||
|
||||
monkeypatch.setattr(service, "servicemanager", FakeServiceManager())
|
||||
monkeypatch.setattr(service, "win32serviceutil", SimpleNamespace(HandleCommandLine=forbidden_handle_command_line))
|
||||
monkeypatch.setattr(service.sys, "argv", [str(SERVICE_WRAPPER)])
|
||||
|
||||
service.main()
|
||||
|
||||
assert calls == ["initialize", ("prepare", service.GTIFiscalAgentService), "dispatcher"]
|
||||
|
||||
|
||||
def test_main_preserves_pywin32_cli_commands(monkeypatch):
|
||||
service = load_service_wrapper()
|
||||
called = []
|
||||
|
||||
class ForbiddenServiceManager:
|
||||
def __getattr__(self, name):
|
||||
raise AssertionError("CLI commands must not enter the SCM dispatcher")
|
||||
|
||||
monkeypatch.setattr(service, "servicemanager", ForbiddenServiceManager())
|
||||
monkeypatch.setattr(service, "win32serviceutil", SimpleNamespace(HandleCommandLine=lambda cls: called.append(cls)))
|
||||
monkeypatch.setattr(service.sys, "argv", [str(SERVICE_WRAPPER), "start"])
|
||||
|
||||
service.main()
|
||||
|
||||
assert called == [service.GTIFiscalAgentService]
|
||||
Reference in New Issue
Block a user