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:
@@ -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"}
|
||||
|
||||
Reference in New Issue
Block a user