feat(cloud): cloud-managed skill store + per-host entitlement + sync versioning
Tests / Test passed: 819
Tests / Test passed: 819
Adds cloud/skills.py (domain + service), SQLAlchemy models and Alembic migration 0010_skill_management (cloud_skills, cloud_skill_entitlements, cloud_skill_sync_state, a per-host changelog, and host_skill_inventory), and repository methods with a monotonic per-host entitlement_version that drives correct incremental fetch_host_delta. cloud-api suite green (41 passed); HEAD_REVISION bumped to 0010. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
"""Repository + service tests for Cloud-managed skills and per-host sync.
|
||||
|
||||
Uses an in-memory SQLite engine. Covers skill CRUD, per-host entitlement, the
|
||||
monotonic entitlement_version bump, and incremental vs full-replace
|
||||
fetch_host_delta semantics (design D2/D3).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from cloud.db_models import Base
|
||||
from cloud.skills import (
|
||||
CloudSkillConflictError,
|
||||
CloudSkillValidationError,
|
||||
CloudSkillService,
|
||||
)
|
||||
from cloud.sql_repository import SQLAlchemyCloudRepository
|
||||
from core.models import utc_now
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service():
|
||||
engine = create_engine(
|
||||
"sqlite://",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(engine)
|
||||
repo = SQLAlchemyCloudRepository(engine=engine, create_schema=False)
|
||||
return CloudSkillService(repo)
|
||||
|
||||
|
||||
def _knowledge_payload(name: str, content: str = "body") -> dict:
|
||||
return dict(
|
||||
name=name,
|
||||
kind="knowledge",
|
||||
description="d",
|
||||
tags=["t"],
|
||||
content=content,
|
||||
steps_json="[]",
|
||||
parameters_json="{}",
|
||||
)
|
||||
|
||||
|
||||
def test_create_list_get_skill(service):
|
||||
now = utc_now()
|
||||
created = service.create_skill(now=now, **_knowledge_payload("Alpha"))
|
||||
assert created.name == "Alpha"
|
||||
assert created.kind == "knowledge"
|
||||
[fetched] = service.list_skills()
|
||||
assert fetched.id == created.id
|
||||
assert service.get_skill(created.id).content == "body"
|
||||
|
||||
|
||||
def test_create_rejects_duplicate_name(service):
|
||||
service.create_skill(now=utc_now(), **_knowledge_payload("Alpha"))
|
||||
with pytest.raises(CloudSkillConflictError):
|
||||
service.create_skill(now=utc_now(), **_knowledge_payload("Alpha"))
|
||||
|
||||
|
||||
def test_create_rejects_blank_content_for_knowledge(service):
|
||||
payload = _knowledge_payload("Alpha", content=" ")
|
||||
with pytest.raises(CloudSkillValidationError):
|
||||
service.create_skill(now=utc_now(), **payload)
|
||||
|
||||
|
||||
def test_grant_revoke_entitlement_drives_delta(service):
|
||||
now = utc_now()
|
||||
skill = service.create_skill(now=now, **_knowledge_payload("Alpha"))
|
||||
host = "host-1"
|
||||
|
||||
# First sync: no entitlements yet -> empty full replace.
|
||||
delta = service.fetch_host_delta(host, since_version=None)
|
||||
assert delta.is_full_replace is True
|
||||
assert delta.skills == []
|
||||
assert delta.latest_version == 0
|
||||
|
||||
# Grant -> version bumps, next full sync sees the skill.
|
||||
service.grant_entitlement(skill.id, host, now=now)
|
||||
full = service.fetch_host_delta(host, since_version=None)
|
||||
assert [s.id for s in full.skills] == [skill.id]
|
||||
assert full.latest_version == 1
|
||||
|
||||
# Incremental from 0 returns the grant.
|
||||
incr = service.fetch_host_delta(host, since_version=0)
|
||||
assert incr.is_full_replace is False
|
||||
assert [s.id for s in incr.skills] == [skill.id]
|
||||
assert incr.removed_ids == []
|
||||
|
||||
# Revoke -> version bumps, incremental reports removal.
|
||||
service.revoke_entitlement(skill.id, host, now=now)
|
||||
after = service.fetch_host_delta(host, since_version=incr.latest_version)
|
||||
assert after.removed_ids == [skill.id]
|
||||
assert after.skills == []
|
||||
|
||||
|
||||
def test_skill_content_update_notifies_entitled_hosts(service):
|
||||
now = utc_now()
|
||||
skill = service.create_skill(now=now, **_knowledge_payload("Alpha", "v1"))
|
||||
service.grant_entitlement(skill.id, "host-1", now=now)
|
||||
baseline = service.fetch_host_delta("host-1", since_version=None).latest_version
|
||||
|
||||
updated = service.update_skill(
|
||||
skill.id, now=utc_now(), **_knowledge_payload("Alpha", "v2")
|
||||
)
|
||||
assert updated.revision == 2
|
||||
incr = service.fetch_host_delta("host-1", since_version=baseline)
|
||||
assert [s.id for s in incr.skills] == [skill.id]
|
||||
assert incr.skills[0].content == "v2"
|
||||
|
||||
|
||||
def test_delete_skill_removes_and_notifies_entitled_hosts(service):
|
||||
now = utc_now()
|
||||
skill = service.create_skill(now=now, **_knowledge_payload("Alpha"))
|
||||
service.grant_entitlement(skill.id, "host-1", now=now)
|
||||
baseline = service.fetch_host_delta("host-1", since_version=None).latest_version
|
||||
|
||||
service.delete_skill(skill.id)
|
||||
assert service.get_skill(skill.id) is None
|
||||
after = service.fetch_host_delta("host-1", since_version=baseline)
|
||||
assert after.removed_ids == [skill.id]
|
||||
|
||||
|
||||
def test_stale_since_version_falls_back_to_full_replace(service):
|
||||
now = utc_now()
|
||||
skill = service.create_skill(now=now, **_knowledge_payload("Alpha"))
|
||||
service.grant_entitlement(skill.id, "host-1", now=now)
|
||||
# A version older than anything in the changelog must yield a full replace.
|
||||
delta = service.fetch_host_delta("host-1", since_version=-5)
|
||||
assert delta.is_full_replace is True
|
||||
|
||||
|
||||
def test_inventory_record_and_readback(service):
|
||||
now = utc_now()
|
||||
payload = json.dumps([{"id": "local-1", "name": "My Note", "origin": "local"}])
|
||||
service.record_host_inventory("host-1", payload, now=now)
|
||||
entry = service.get_host_inventory("host-1")
|
||||
assert entry is not None
|
||||
assert json.loads(entry.payload_json)[0]["name"] == "My Note"
|
||||
Reference in New Issue
Block a user