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]
|
||||
@@ -194,6 +194,7 @@ def router(session_factory):
|
||||
target_type="agent", target_id=str(agent.id), context={"ip": ip, "installation": enr.enrollment_uuid})
|
||||
session.commit()
|
||||
return {"agent_id": agent.id, "agent_token": plain_token, "tenant_id": enr.tenant_id,
|
||||
"installation_id": enr.enrollment_uuid,
|
||||
"hub_url": config.settings.base_url, "hostname_mismatch": mismatch}
|
||||
except enrollment.EnrollmentRejected as rejected:
|
||||
session.rollback()
|
||||
|
||||
@@ -226,6 +226,26 @@ def router(session_factory, bootstrap_token):
|
||||
return {"event_id": event.id}
|
||||
finally: session.close()
|
||||
|
||||
@routes.get("/agents/me/identity")
|
||||
def agent_identity(identity=Depends(principal)):
|
||||
"""Return only the authenticated Agent's existing installation identity."""
|
||||
session, agent = identity
|
||||
try:
|
||||
installation = session.scalar(select(Installation).where(Installation.agent_id == agent.id))
|
||||
last_seen = agent.last_seen_at
|
||||
if last_seen is not None and last_seen.tzinfo is None:
|
||||
last_seen = last_seen.replace(tzinfo=timezone.utc)
|
||||
online = last_seen is not None and (datetime.now(timezone.utc) - last_seen).total_seconds() <= settings.agent_online_threshold_seconds
|
||||
return {
|
||||
"agent_id": agent.id,
|
||||
"installation_id": installation.installation_id if installation else None,
|
||||
"hostname": installation.hostname if installation else None,
|
||||
"version": agent.agent_version,
|
||||
"status": "ONLINE" if online else "OFFLINE",
|
||||
}
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
@routes.get("/agents/me")
|
||||
def agent_status(identity=Depends(principal)):
|
||||
session, agent = identity
|
||||
|
||||
@@ -128,6 +128,7 @@ def test_enroll_with_install_code_alone_creates_agent(hub, release_id):
|
||||
assert enrolled.status_code == 200
|
||||
body = enrolled.json()
|
||||
assert body["agent_id"] and body["agent_token"] and body["hostname_mismatch"] is False
|
||||
assert body["installation_id"] == uuid
|
||||
|
||||
with factory() as session:
|
||||
agent = session.get(Agent, body["agent_id"])
|
||||
@@ -416,4 +417,4 @@ def test_revoke_clears_code_and_is_audited(hub, tmp_path):
|
||||
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == installation_uuid))
|
||||
assert enr.status == "REVOKED" and enr.install_code_hash is None
|
||||
events = {e.action for e in audits(factory)}
|
||||
assert "INSTALLATION_CODE_CREATED" in events and "INSTALLATION_CODE_REVOKED" in events and "INSTALLATION_REVOKED" in events
|
||||
assert "INSTALLATION_CODE_CREATED" in events and "INSTALLATION_CODE_REVOKED" in events and "INSTALLATION_REVOKED" in events
|
||||
|
||||
@@ -5,7 +5,7 @@ from sqlalchemy.pool import StaticPool
|
||||
|
||||
from central.app.jobs import claim_next_job, complete_job, create_job, fail_job
|
||||
from central.app.main import create_app
|
||||
from central.app.models import Agent, Base, BlockedProduct, Job, Tenant
|
||||
from central.app.models import Agent, Base, BlockedProduct, Installation, Job, Tenant
|
||||
|
||||
|
||||
def _make_agent(session):
|
||||
@@ -118,6 +118,63 @@ def test_agent_status_derives_online_offline():
|
||||
assert "company_id" in body
|
||||
|
||||
|
||||
def test_agent_identity_endpoint_returns_existing_installation_identity_only():
|
||||
client, _ = _client()
|
||||
headers = {"Authorization": "Bearer test-token"}
|
||||
installation_id = "00000000-0000-4000-8000-000000000222"
|
||||
|
||||
heartbeat = client.post(
|
||||
"/v1/agents/heartbeat",
|
||||
json={
|
||||
"installation_id": installation_id,
|
||||
"hostname": "DESKTOP-B0HL02I",
|
||||
"agent_version": "1.1.1",
|
||||
"status": "IDLE",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert heartbeat.status_code == 200
|
||||
|
||||
response = client.get("/v1/agents/me/identity", headers=headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"agent_id": heartbeat.json()["agent_id"],
|
||||
"installation_id": installation_id,
|
||||
"hostname": "DESKTOP-B0HL02I",
|
||||
"version": "1.1.1",
|
||||
"status": "ONLINE",
|
||||
}
|
||||
|
||||
|
||||
def test_agent_identity_endpoint_returns_null_installation_without_creating_one():
|
||||
client, engine = _client()
|
||||
headers = {"Authorization": "Bearer test-token"}
|
||||
|
||||
response = client.get("/v1/agents/me/identity", headers=headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body == {
|
||||
"agent_id": body["agent_id"],
|
||||
"installation_id": None,
|
||||
"hostname": None,
|
||||
"version": None,
|
||||
"status": "OFFLINE",
|
||||
}
|
||||
assert isinstance(body["agent_id"], int) and body["agent_id"] > 0
|
||||
with Session(engine) as session:
|
||||
assert session.query(Installation).count() == 0
|
||||
|
||||
|
||||
def test_agent_identity_endpoint_requires_agent_authentication():
|
||||
client, _ = _client()
|
||||
|
||||
response = client.get("/v1/agents/me/identity")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_job_creation_rejects_unknown_kind():
|
||||
client, _ = _client()
|
||||
headers = {"Authorization": "Bearer test-token"}
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
# Agent Installation Identity Recovery and First Heartbeat Implementation Plan
|
||||
|
||||
> **For agentic workers:** Use `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox syntax for tracking.
|
||||
|
||||
**Goal:** Recover and persist Agent 2's existing Hub `installation_id`, send exactly the first safe heartbeat, and stop without claiming jobs or writing Firebird.
|
||||
|
||||
**Architecture:** The Hub exposes the already-linked installation identity over an authenticated, read-only endpoint. New enrollments return that same ID so Setup can persist it; legacy installations recover it at runtime and commit it into the existing SQLite identity table before heartbeat. `AGENT_JOBS_ENABLED=0` skips the job API entirely during activation.
|
||||
|
||||
**Tech Stack:** FastAPI, SQLAlchemy, pytest, Python `fdb`, SQLite local state, PyInstaller onedir service host, Windows SCM / PowerShell.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Keep Agent ID `2`; do not create an Agent, reenroll, or create a second Installation.
|
||||
- Never generate a fallback `installation_id` for an already-registered Agent.
|
||||
- Do not call `/v1/jobs/next`, classification endpoints, or any job execution path during activation.
|
||||
- Preserve `FIREBIRD_WRITES=0`, `FISCAL_MODE=DRY_RUN`, and empty `AUTHORIZED_WRITE_DATABASE_PATH`.
|
||||
- All Firebird connections used by preflight remain read-only; issue no Firebird DML/DDL.
|
||||
- Do not rerun Setup.exe or replace `agent.env` / `credentials.bin` during service-host update.
|
||||
- Keep published `GTIFiscalAgent-Setup-1.1.1.exe` immutable; update only the installed service runtime.
|
||||
- Never print or log `FB_PASSWORD`, the Agent bearer token, or credential-file contents.
|
||||
- Do not commit unless the operator explicitly requests a commit.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Hub installation identity response
|
||||
|
||||
**Files:**
|
||||
- Modify: `central/app/api.py`
|
||||
- Test: `central/tests/test_jobs.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Add authenticated `GET /v1/agents/me/identity`.
|
||||
- Return exactly `{agent_id, installation_id, hostname, version, status}`; `installation_id` and `hostname` are `None` if the Agent has no Installation.
|
||||
- Do not change the existing `GET /v1/agents/me` response.
|
||||
|
||||
- [ ] **Step 1: Write the failing identity endpoint tests**
|
||||
|
||||
Add tests that create an Agent with one Installation (`installation_id` fixed UUID, hostname `DESKTOP-B0HL02I`, version `1.1.1`) and assert:
|
||||
|
||||
```python
|
||||
response = client.get("/v1/agents/me/identity", headers=headers)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"agent_id": agent.id,
|
||||
"installation_id": installation.installation_id,
|
||||
"hostname": "DESKTOP-B0HL02I",
|
||||
"version": "1.1.1",
|
||||
"status": "OFFLINE",
|
||||
}
|
||||
```
|
||||
|
||||
Also assert unauthenticated requests return 401 and an Agent without an Installation returns HTTP 200 with nullable identity fields, without creating an Installation.
|
||||
|
||||
- [ ] **Step 2: Run the focused tests and verify failure**
|
||||
|
||||
Run: `python -m pytest central/tests/test_jobs.py -q`
|
||||
Expected: the identity route returns 404 before implementation.
|
||||
|
||||
- [ ] **Step 3: Implement the authenticated endpoint**
|
||||
|
||||
Use the existing `principal` dependency, query `Installation` by `agent.id`, and derive status with the same online-threshold rule as `/v1/agents/me`. The route must not commit or mutate database state.
|
||||
|
||||
- [ ] **Step 4: Run the focused tests**
|
||||
|
||||
Run: `python -m pytest central/tests/test_jobs.py -q`
|
||||
Expected: all tests pass; existing `/v1/agents/me` tests remain unchanged and green.
|
||||
|
||||
### Task 2: Return and persist identity during new enrollment
|
||||
|
||||
**Files:**
|
||||
- Modify: `central/app/agent_distribution.py`
|
||||
- Test: `central/tests/test_install_code_enrollment.py`
|
||||
- Modify: `installer/install_common.py`
|
||||
- Test: `tests/test_installer_artifacts.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Successful `/v1/agents/enroll` response adds `installation_id=enr.enrollment_uuid`.
|
||||
- `write_agent_env(install_dir_path, enroll_result)` writes `INSTALLATION_ID` as a non-secret field. It never writes `agent_token` or `AGENT_TOKEN`.
|
||||
|
||||
- [ ] **Step 1: Add failing enrollment and Setup-env assertions**
|
||||
|
||||
In the existing install-code happy-path test, assert `body["installation_id"] == uuid`. In the installer artifact test, pass a fixed ID such as `00000000-0000-4000-8000-000000000222` and assert the env includes it while the token is absent.
|
||||
|
||||
- [ ] **Step 2: Run focused tests and verify failure**
|
||||
|
||||
Run: `python -m pytest central/tests/test_install_code_enrollment.py::test_enroll_with_install_code_alone_creates_agent tests/test_installer_artifacts.py -q`
|
||||
Expected: failures show the response/env currently omit `installation_id`.
|
||||
|
||||
- [ ] **Step 3: Add the returned ID and env serialization**
|
||||
|
||||
Add `"installation_id": enr.enrollment_uuid` to the successful enrollment JSON. Add `INSTALLATION_ID` to `write_agent_env`; do not change credential storage or Firebird guard values.
|
||||
|
||||
- [ ] **Step 4: Run focused tests**
|
||||
|
||||
Run the same command as Step 2.
|
||||
Expected: enrollment and installer artifact tests pass; `agent.env` remains token-free with `FIREBIRD_WRITES=0` and `FISCAL_MODE=DRY_RUN`.
|
||||
|
||||
### Task 3: Persist or recover identity before heartbeat
|
||||
|
||||
**Files:**
|
||||
- Modify: `agent/src/gti_fiscal_agent/state.py`
|
||||
- Modify: `agent/src/gti_fiscal_agent/client.py`
|
||||
- Modify: `agent/scripts/run_agent_service.py`
|
||||
- Test: `agent/tests/test_state.py`
|
||||
- Test: `agent/tests/test_run_agent_service.py`
|
||||
|
||||
**Interfaces:**
|
||||
- `AgentState.installation_id(candidate: str | None = None) -> str` returns the persisted UUID; if no UUID is stored, it persists a valid candidate; if neither exists, it raises instead of generating a random UUID. A different candidate from an already-persisted UUID raises.
|
||||
- `HubClient.identity() -> dict` calls authenticated `GET /v1/agents/me/identity`.
|
||||
- `resolve_installation_id(config, env, state, client) -> str` selects persisted state, then `INSTALLATION_ID`, then authenticated identity; verifies returned `agent_id` equals `config.agent_id`; persists before returning.
|
||||
|
||||
- [ ] **Step 1: Write failing state identity tests**
|
||||
|
||||
Cover: empty store plus valid candidate persists and returns it; second call returns the same ID; conflicting candidate raises; missing candidate does not generate; malformed UUID raises.
|
||||
|
||||
- [ ] **Step 2: Run state tests and verify failure**
|
||||
|
||||
Run: `python -m pytest agent/tests/test_state.py -q`
|
||||
Expected: the missing identity case currently generates a UUID and the persistence API is absent.
|
||||
|
||||
- [ ] **Step 3: Implement deterministic SQLite identity persistence**
|
||||
|
||||
Validate candidate values with `uuid.UUID`. Within a SQLite transaction, read the existing row, compare any candidate, insert only when absent, and return the single stored canonical UUID. Remove random UUID creation from the registered-agent path.
|
||||
|
||||
- [ ] **Step 4: Write failing runtime bootstrap tests**
|
||||
|
||||
Use a fake Hub identity response and fake state. Assert missing local identity calls `identity()` once, verifies matching Agent ID, persists the returned UUID, and uses it in heartbeat. Assert a mismatched Agent ID, null ID, or identity request error fails before heartbeat. Assert an already-persisted ID avoids the identity request.
|
||||
|
||||
- [ ] **Step 5: Run runtime tests and verify failure**
|
||||
|
||||
Run: `python -m pytest agent/tests/test_run_agent_service.py -q`
|
||||
Expected: current loop calls the random-UUID-producing state method and has no authenticated identity bootstrap.
|
||||
|
||||
- [ ] **Step 6: Implement identity bootstrap before the heartbeat loop**
|
||||
|
||||
Add `HubClient.identity()`, implement `resolve_installation_id`, and invoke it after constructing the Hub client/state but before entering the loop. An identity failure must exit before heartbeat and before job polling.
|
||||
|
||||
- [ ] **Step 7: Run focused state/runtime tests**
|
||||
|
||||
Run: `python -m pytest agent/tests/test_state.py agent/tests/test_run_agent_service.py -q`
|
||||
Expected: persisted/recovered identity tests pass and mismatches fail closed.
|
||||
|
||||
### Task 4: Disable job polling without disabling heartbeat
|
||||
|
||||
**Files:**
|
||||
- Modify: `agent/scripts/run_agent_service.py`
|
||||
- Test: `agent/tests/test_run_agent_service.py`
|
||||
- Modify on this host only: `C:\Program Files\GTI\FiscalAgent\agent.env`
|
||||
|
||||
**Interfaces:**
|
||||
- `Config.agent_jobs_enabled` parses `AGENT_JOBS_ENABLED`, default `True`.
|
||||
- When false, the loop sleeps for `poll_interval_seconds` and continues after heartbeat without calling `HubClient.next_job()`.
|
||||
|
||||
- [ ] **Step 1: Add the failing no-poll test**
|
||||
|
||||
Use a fake Hub whose `next_job()` raises if called. Make its first heartbeat set the external stop flag. Run one loop cycle with `AGENT_JOBS_ENABLED=0` and assert one heartbeat, zero `next_job` calls, and zero job/classification calls.
|
||||
|
||||
- [ ] **Step 2: Run the focused test and verify failure**
|
||||
|
||||
Run: `python -m pytest agent/tests/test_run_agent_service.py -q`
|
||||
Expected: the current loop calls `next_job()` immediately after heartbeat.
|
||||
|
||||
- [ ] **Step 3: Implement the gate with a backward-compatible default**
|
||||
|
||||
Parse `AGENT_JOBS_ENABLED` as enabled unless its stripped, uppercased value is `0`, `FALSE`, or `NO`. After successful heartbeat, if disabled, sleep once and continue before constructing or calling the job endpoint.
|
||||
|
||||
- [ ] **Step 4: Run focused runtime tests**
|
||||
|
||||
Run: `python -m pytest agent/tests/test_run_agent_service.py -q`
|
||||
Expected: disabled polling makes no `next_job` request; default configuration retains the existing job behavior.
|
||||
|
||||
### Task 5: Full regressions and guard review
|
||||
|
||||
**Files:**
|
||||
- Verify: `central/tests/`, `agent/tests/`, `tests/`
|
||||
|
||||
- [ ] **Step 1: Run Hub and Agent regressions**
|
||||
|
||||
Run: `python -m pytest central/tests -q` and `python -m pytest agent/tests -q`.
|
||||
Expected: both suites pass.
|
||||
|
||||
- [ ] **Step 2: Run repository offline regressions**
|
||||
|
||||
Run: `python -m pytest tests -q`.
|
||||
Expected: all tests pass with only the repository's pre-existing skip.
|
||||
|
||||
- [ ] **Step 3: Verify write and secret invariants**
|
||||
|
||||
Assert tests and source still guarantee `FIREBIRD_WRITES=0`, `FISCAL_MODE=DRY_RUN`, empty `AUTHORIZED_WRITE_DATABASE_PATH`, no token in `agent.env`, and identity endpoint contains only the approved fields. Do not connect to Firebird for this check.
|
||||
|
||||
### Task 6: Deploy the API and update only the installed service runtime
|
||||
|
||||
**Files/artifacts:**
|
||||
- Deploy Hub source: `central/app/api.py`, `central/app/agent_distribution.py`
|
||||
- Build artifact: `dist/installer/GTIFiscalAgentService/` only
|
||||
- Existing install: `C:\Program Files\GTI\FiscalAgent\GTIFiscalAgentService\`
|
||||
|
||||
- [ ] **Step 1: Deploy the Hub code without a migration**
|
||||
|
||||
Use the approved production deployment path and preserve the existing Hub `.env`, PostgreSQL volume, release volume, SSO, and DNS. Build/restart only `fiscal-hub`; verify `/health`, `/version`, and authenticated `/v1/agents/me/identity` return Agent 2's already-existing identity. Do not query the database directly to retrieve the ID.
|
||||
|
||||
- [ ] **Step 2: Build only the service-host onedir**
|
||||
|
||||
Record SHA-256 of `dist/installer/GTIFiscalAgent-Setup-1.1.1.exe` before building. Verify no unversioned `GTIFiscalAgent-Setup.exe` exists, then run `scripts/build_windows_installer.ps1 -SkipSetup -SkipUninstall`. Confirm the versioned Setup hash is unchanged and no Setup.exe is run.
|
||||
|
||||
- [ ] **Step 3: Back up and replace only the service runtime**
|
||||
|
||||
With `GTIFiscalAgent` stopped, copy the current installed `GTIFiscalAgentService` directory to a timestamped local backup. Replace only that directory with the newly built service onedir. Leave `agent.env`, `credentials.bin`, `Uninstall.exe`, registry/ARP entries, and state files untouched.
|
||||
|
||||
- [ ] **Step 4: Prepare the existing installation for heartbeat-only operation**
|
||||
|
||||
Elevated-edit `agent.env` to set `AGENT_JOBS_ENABLED=0`, preserving `FIREBIRD_WRITES=0`, `FISCAL_MODE=DRY_RUN`, the existing Agent ID, Firebird configuration, and SYSTEM/Administrators ACL. Do not put the bearer token in this file.
|
||||
|
||||
- [ ] **Step 5: Verify recovery before SCM start**
|
||||
|
||||
Run the installed runtime's read-only identity bootstrap/preflight with the existing DPAPI credential. Confirm recovered `agent_id=2`, `installation_id` equals the endpoint value, the SQLite state contains that same ID, and jobs are disabled. If any check fails, leave the service stopped.
|
||||
|
||||
### Task 7: First heartbeat only, then stop
|
||||
|
||||
**Files:**
|
||||
- Existing Windows service: `GTIFiscalAgent`
|
||||
- Existing Hub identity endpoint: `GET /v1/agents/me/identity`
|
||||
|
||||
- [ ] **Step 1: Capture a baseline**
|
||||
|
||||
Before service start, record the existing local `ipi_write_log` row count and confirm service state is STOPPED. Do not call `/v1/jobs/next` or submit classification requests.
|
||||
|
||||
- [ ] **Step 2: Start the existing service**
|
||||
|
||||
Run `sc.exe start GTIFiscalAgent`; wait for SCM state RUNNING and verify the identity endpoint shows `agent_id=2`, the same stored `installation_id`, correct hostname, version `1.1.1`, and status ONLINE.
|
||||
|
||||
- [ ] **Step 3: Stop immediately after first heartbeat**
|
||||
|
||||
Run `sc.exe stop GTIFiscalAgent`; verify STOPPED. Keep `AGENT_JOBS_ENABLED=0` in `agent.env`.
|
||||
|
||||
- [ ] **Step 4: Verify zero jobs and writes**
|
||||
|
||||
Compare local `ipi_write_log` count with baseline; assert unchanged and equal to zero. Confirm no classification request or job was sent, `FIREBIRD_WRITES=0`, and `WRITE_ATTEMPTS=0` / `WRITE_SUCCESSES=0`. Verify authenticated identity still maps to Agent 2 and the original installation ID; do not create or enroll anything.
|
||||
|
||||
- [ ] **Step 5: Verify user-visible status if panel access is available**
|
||||
|
||||
Confirm Agent 2 displays ONLINE with the host name and version 1.1.1. If the panel requires unavailable human authentication, report the authenticated identity endpoint evidence and leave the panel UI item as a blocker; do not bypass authentication.
|
||||
|
||||
## Rollback and stop gates
|
||||
|
||||
- If Hub identity response is absent, mismatched, or does not name Agent 2, stop before writing the local identity or starting SCM.
|
||||
- If Hub deployment or service-host replacement fails, restore the backed-up service directory and leave the service stopped.
|
||||
- If `AGENT_JOBS_ENABLED=0` is not verified, do not start the service.
|
||||
- If preflight, identity, hostname, version, or write guards fail, stop and report; do not retry enrollment, generate another UUID, run a job, or write Firebird.
|
||||
@@ -0,0 +1,107 @@
|
||||
# Agent Installation Identity Recovery and First Heartbeat
|
||||
|
||||
## Goal and scope
|
||||
|
||||
Activate the already-enrolled Agent 2 on this Windows host without creating a
|
||||
new Agent, enrolling again, or registering a second installation. Recover and
|
||||
persist its existing Hub `installation_id`, send a first real heartbeat, and
|
||||
stop before any job is claimed. Firebird stays strictly read-only and the
|
||||
installation remains `FIREBIRD_WRITES=0` / `FISCAL_MODE=DRY_RUN`.
|
||||
|
||||
## Current root cause
|
||||
|
||||
`POST /v1/agents/enroll` creates the Hub `Installation` with
|
||||
`installation_id=enrollment_uuid`, but its response omits that identity.
|
||||
`installer/setup_main.py` therefore does not persist it. On a host with no
|
||||
`agent_state.sqlite`, `AgentState.installation_id()` invents a UUID. The Hub
|
||||
heartbeat route rejects a different UUID for an Agent that already has an
|
||||
Installation, preventing the first heartbeat.
|
||||
|
||||
The service loop also calls `GET /v1/jobs/next` immediately after a successful
|
||||
heartbeat, so an explicit runtime gate is required for this activation.
|
||||
|
||||
## Design
|
||||
|
||||
### Identity delivery and recovery
|
||||
|
||||
- Return `installation_id` in the successful enrollment response. The value is
|
||||
the existing `enrollment_uuid` used to create the Hub Installation.
|
||||
- Persist this non-secret value in `agent.env` for new installs.
|
||||
- Add authenticated `GET /v1/agents/me/identity`, returning only
|
||||
`agent_id`, `installation_id`, `hostname`, `version`, and `status`. Keep the
|
||||
existing `/v1/agents/me` response unchanged for compatibility.
|
||||
- At runtime, identity precedence is: existing SQLite `installation_identity`,
|
||||
then configured `INSTALLATION_ID`, then the authenticated identity endpoint.
|
||||
Recovery must verify the returned `agent_id` equals local `AGENT_ID`; absent,
|
||||
malformed, or mismatched identity fails closed without generating a UUID or
|
||||
sending a heartbeat.
|
||||
- Persist a recovered/configured UUID atomically in the existing SQLite state
|
||||
before sending heartbeat. Restarts reuse this stored ID. A conflict between
|
||||
configured and already-persisted values fails closed.
|
||||
|
||||
### Job-poll gate
|
||||
|
||||
- Add `AGENT_JOBS_ENABLED`, defaulting to `1` to preserve current behavior for
|
||||
existing deployments.
|
||||
- When it is `0`, the loop may send heartbeat and sleep, but must not call
|
||||
`next_job`, claim jobs, classify products, or execute jobs.
|
||||
- Set `AGENT_JOBS_ENABLED=0` only for this existing installation while
|
||||
validating the first heartbeat. Leave it disabled after the service is
|
||||
stopped, until a separate operator action authorizes job processing.
|
||||
|
||||
### Deployment boundary
|
||||
|
||||
- The identity endpoint is Hub application code only; it needs no DB migration.
|
||||
Deploy that Hub code without changing the immutable Agent release or the
|
||||
production SSO/DNS configuration.
|
||||
- The installed service binary must contain the identity recovery and job gate.
|
||||
Build only the service-host onedir and update the existing
|
||||
`GTIFiscalAgentService` payload while the service is stopped. Do not run Setup,
|
||||
replace `agent.env`/`credentials.bin`, recreate an Agent, or enroll again.
|
||||
- Keep the published 1.1.1 Setup artifact immutable. The installer source fix
|
||||
gets regression coverage for a future release; this activation updates only
|
||||
the installed service runtime needed by the current host.
|
||||
|
||||
### Write safety
|
||||
|
||||
No Firebird write path is added or enabled. The existing agent configuration
|
||||
must continue to contain `FIREBIRD_WRITES=0`, `FISCAL_MODE=DRY_RUN`, and an empty
|
||||
`AUTHORIZED_WRITE_DATABASE_PATH`. The first heartbeat reports metadata only;
|
||||
this mission must not call `/jobs/next` or a classification endpoint.
|
||||
|
||||
## Failure behavior
|
||||
|
||||
- Identity endpoint unavailable, no existing installation, or Agent ID mismatch:
|
||||
fail closed before heartbeat and before job polling.
|
||||
- SQLite persistence failure: fail closed; do not send a guessed identity.
|
||||
- Jobs disabled: continue the heartbeat loop without touching the job API.
|
||||
- If Hub deployment or the service-host update cannot be completed, do not
|
||||
start the installed service; report the deployment blocker instead.
|
||||
- Service is stopped immediately after the first successful heartbeat is
|
||||
observed and verified against Agent 2's existing installation.
|
||||
|
||||
## Regression coverage
|
||||
|
||||
1. Enrollment response returns the exact `enrollment_uuid` as
|
||||
`installation_id`; Setup persists it without placing any token in `agent.env`.
|
||||
2. Authenticated identity endpoint returns only the approved identity fields
|
||||
and derives hostname/version/status from the existing Agent/Installation.
|
||||
3. Runtime recovers and persists an ID when local state is missing, validates
|
||||
Agent ID equality, reuses the ID on restart, and fails closed on mismatch or
|
||||
missing identity.
|
||||
4. With `AGENT_JOBS_ENABLED=0`, heartbeat runs but `next_job` is never called;
|
||||
default-enabled behavior remains covered.
|
||||
5. Existing write-safety tests continue to prove zero Firebird writes in
|
||||
`DRY_RUN` / `FIREBIRD_WRITES=0`.
|
||||
|
||||
## Acceptance for this activation
|
||||
|
||||
- Existing Agent ID remains 2; no enrollment or Agent creation occurs.
|
||||
- Recovered identity equals the Hub's existing `Installation.installation_id`
|
||||
and is persisted locally before start.
|
||||
- Jobs are disabled before service start; service reaches RUNNING, sends one
|
||||
successful heartbeat for the existing installation, then is stopped.
|
||||
- Panel shows Agent 2 online with the correct hostname and version 1.1.1; no
|
||||
duplicate installation is created.
|
||||
- `FIREBIRD_WRITES=0`, `WRITE_ATTEMPTS=0`, `WRITE_SUCCESSES=0`; no job polling,
|
||||
classification, or Firebird writes occur.
|
||||
@@ -73,6 +73,7 @@ def write_agent_env(install_dir_path: Path, enroll_result: dict) -> None:
|
||||
lines = [
|
||||
(f"GTI_HUB_URL={enroll_result.get('hub_url') or hub_url()}"),
|
||||
(f"AGENT_ID={enroll_result.get('agent_id') or ''}"),
|
||||
(f"INSTALLATION_ID={enroll_result.get('installation_id') or ''}"),
|
||||
("COMPANY_ID="),
|
||||
("COMPANY_UF="),
|
||||
("FIREBIRD_DATABASE_PATH="),
|
||||
@@ -182,4 +183,4 @@ def audit_event(agent_token: str, hub: str, event_type: str, message: str) -> No
|
||||
)
|
||||
response.raise_for_status()
|
||||
except Exception: # noqa: BLE001 — best effort only
|
||||
return
|
||||
return
|
||||
|
||||
@@ -42,7 +42,7 @@ def test_agent_env_is_secret_free_and_write_safe(tmp_path, install_common, monke
|
||||
monkeypatch.setenv("AGENT_VERSION", "1.1.1")
|
||||
final = ROOT / "release" / "VERSION"
|
||||
monkeypatch.setenv("AGENT_VERSION", final.read_text(encoding="utf-8").strip())
|
||||
result = {"agent_id": 42, "agent_token": secret, "hub_url": "https://gtifiscal.gtigrupo.com.br", "agent_version": "1.1.1"}
|
||||
result = {"agent_id": 42, "agent_token": secret, "hub_url": "https://gtifiscal.gtigrupo.com.br", "agent_version": "1.1.1", "installation_id": "00000000-0000-4000-8000-000000000222"}
|
||||
install_common.write_agent_env(tmp_path, result)
|
||||
env = (tmp_path / "agent.env").read_text(encoding="utf-8")
|
||||
assert "AGENT_TOKEN" not in env
|
||||
@@ -52,6 +52,7 @@ def test_agent_env_is_secret_free_and_write_safe(tmp_path, install_common, monke
|
||||
assert "FISCAL_MODE=DRY_RUN" in env
|
||||
assert "GTI_HUB_URL=https://gtifiscal.gtigrupo.com.br" in env
|
||||
assert "AGENT_ID=42" in env
|
||||
assert "INSTALLATION_ID=00000000-0000-4000-8000-000000000222" in env
|
||||
assert "FIREBIRD_DATABASE_PATH=" in env
|
||||
for k in ("AGENT_LOG_PATH", "AGENT_STATE_DB", "EXPECTED_SCHEMA_FINGERPRINT", "GTI_WRITE_EXECUTOR_PATH"):
|
||||
assert k in env, k
|
||||
@@ -118,4 +119,4 @@ def test_no_writes_ever_enabled_in_installer_sources():
|
||||
assert "AUTO_APPLY" not in text
|
||||
assert "FIREBIRD_WRITES=0" not in text # the flag lives only in install_common
|
||||
assert "FISCAL_MODE=DRY_RUN" in install_common_text
|
||||
assert "FIREBIRD_WRITES=0" in install_common_text
|
||||
assert "FIREBIRD_WRITES=0" in install_common_text
|
||||
|
||||
Reference in New Issue
Block a user