feat: implement daemon model with registry and single-asset assignment (BAT-010, REQ-023/028)
Core Implementation (BAT-010):
- Add daemon registry schema and CRUD operations (req-023)
- Implement single-daemon asset assignment constraint (one asset per daemon)
- Add daemon_id field to service_token for daemon identification
- Add daemon.py CLI module for daemon management commands
- Add 20260513_0016_daemon_model.py migration for daemon tables
API Changes:
- GET /v1/assets: include daemon_id in AssetResponse
- POST /v1/assets/{asset_id}/ledger/trades: require daemon token binding
- GET /v1/user_info: include daemon metadata
- Enhanced service-token rotate to preserve daemon bindings
CLI Commands:
- pfhub daemon register --name <name> --runtime-id <runtime-id>
- pfhub daemon list
- pfhub daemon get <daemon-id>
- pfhub daemon revoke <daemon-id>
Requirements (REQ-023, REQ-028):
- REQ-023: Daemon Registry and Service Token Daemon Binding (Interface, Approved)
* AC-1: daemon CRUD operations
* AC-2: service token daemon binding for POST /v1/assets/{asset_id}/ledger/trades
* AC-3: single daemon per asset (exact-one assignment model)
- REQ-028: README.md Daemon Provisioning Runbook update (Documentation, Approved)
Documentation:
- Add dcy-006 Discovery for daemon-id and single-daemon asset assignment
- Update README.md with Daemon Provisioning Runbook section
- Add BAT-010 batch documentation with planning, implementation, verification
- Update rel-006 release document (daemon model implementation)
- Update data-model.md with daemon and daemon_asset_assignment tables
- Update interface-contract.md with daemon registry APIs
- Update runtime-flows.md with daemon lifecycle flows
Breaking Changes:
- REQ-005~009, REQ-011~012 status reverted to Approved due to revised contract
* daemon_id field addition and exact-one asset assignment requirement
* Existing Implemented evidence invalidated by contract revision
* BAT-001/002/008 re-verification required
Testing:
- Add test_daemon_registry.py for daemon CRUD operations
- Add test_daemon_assignment.py for single-asset assignment constraints
- Update test_auth.py, test_cli.py, test_v1.py for daemon support
- Verify daemon token binding in trade endpoint authorization
Impact:
- Database: 2 new tables (daemons, daemon_asset_assignments)
- Migration: 20260513_0016 creates daemon model schema
- CLI: 4 new daemon subcommands
- API: daemon_id propagation through v1 endpoints
- Verification: BAT-001/002/008 re-verification gates next release
This commit is contained in:
+31
-15
@@ -87,6 +87,7 @@ class _RuntimeIdRoundTripRepo:
|
||||
token_type: str,
|
||||
expires_at: datetime | None = None,
|
||||
runtime_id: str | None = None,
|
||||
daemon_id: str | None = None,
|
||||
) -> AuthToken:
|
||||
token = AuthToken(
|
||||
id=f"token-{self._seq}",
|
||||
@@ -98,6 +99,7 @@ class _RuntimeIdRoundTripRepo:
|
||||
expires_at=expires_at,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
runtime_id=runtime_id,
|
||||
daemon_id=daemon_id,
|
||||
)
|
||||
self._seq += 1
|
||||
self.tokens_by_hash[token_hash] = token
|
||||
@@ -113,6 +115,7 @@ class _RuntimeIdRoundTripRepo:
|
||||
permission=token.permission,
|
||||
token_type=token.token_type,
|
||||
runtime_id=token.runtime_id,
|
||||
daemon_id=token.daemon_id,
|
||||
)
|
||||
|
||||
def list_broker_credentials(self, user_id: str) -> list[BrokerCredential]:
|
||||
@@ -226,6 +229,7 @@ def _make_portfolio(portfolio_id: str = "p1") -> Portfolio:
|
||||
active_strategy_id=None,
|
||||
avabile_strategies=None,
|
||||
owner_user_id="user-1",
|
||||
managed_by_daemon_id="d-1",
|
||||
)
|
||||
|
||||
|
||||
@@ -253,7 +257,7 @@ def test_v1_assets_returns_asset_list(monkeypatch) -> None:
|
||||
portfolio = _make_portfolio()
|
||||
principal = TokenPrincipal(
|
||||
token_id="t1", user_id="user-1", permission="read",
|
||||
token_type="service", runtime_id="rt-1"
|
||||
token_type="service", runtime_id="rt-1", daemon_id="d-1"
|
||||
)
|
||||
|
||||
mock_repo = MagicMock()
|
||||
@@ -280,6 +284,7 @@ def test_v1_assets_returns_asset_list(monkeypatch) -> None:
|
||||
mock_repo.list_portfolios_with_strategies.assert_called_once_with(
|
||||
owner_user_id="user-1",
|
||||
include_disabled=False,
|
||||
managed_by_daemon_id="d-1",
|
||||
)
|
||||
|
||||
|
||||
@@ -294,7 +299,7 @@ def test_v1_assets_status_filter(monkeypatch) -> None:
|
||||
)
|
||||
principal = TokenPrincipal(
|
||||
token_id="t1", user_id="user-1", permission="read",
|
||||
token_type="service", runtime_id="rt-1"
|
||||
token_type="service", runtime_id="rt-1", daemon_id="d-1"
|
||||
)
|
||||
|
||||
mock_repo = MagicMock()
|
||||
@@ -328,7 +333,7 @@ def test_v1_assets_strategy_params(monkeypatch) -> None:
|
||||
)
|
||||
principal = TokenPrincipal(
|
||||
token_id="t1", user_id="user-1", permission="read",
|
||||
token_type="service", runtime_id="rt-1"
|
||||
token_type="service", runtime_id="rt-1", daemon_id="d-1"
|
||||
)
|
||||
|
||||
mock_repo = MagicMock()
|
||||
@@ -357,7 +362,7 @@ def test_v1_assets_strategy_params_fallback_on_error(monkeypatch) -> None:
|
||||
)
|
||||
principal = TokenPrincipal(
|
||||
token_id="t1", user_id="user-1", permission="read",
|
||||
token_type="service", runtime_id="rt-1"
|
||||
token_type="service", runtime_id="rt-1", daemon_id="d-1"
|
||||
)
|
||||
|
||||
mock_repo = MagicMock()
|
||||
@@ -379,23 +384,27 @@ def test_v1_assets_strategy_params_fallback_on_error(monkeypatch) -> None:
|
||||
# GET /v1/user_info
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_v1_user_info_404_when_no_runtime_id(monkeypatch) -> None:
|
||||
def test_v1_user_info_null_runtime_id_returns_200_with_null(monkeypatch) -> None:
|
||||
settings = _make_app_db(monkeypatch)
|
||||
|
||||
principal = TokenPrincipal(
|
||||
token_id="t1", user_id="user-1", permission="read",
|
||||
token_type="service", runtime_id=None, # no runtime_id
|
||||
)
|
||||
user = User(id="user-1", username="alice", permission="read", is_active=True)
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get_user.return_value = user
|
||||
mock_repo.list_broker_credentials.return_value = []
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_app(_make_settings(credential_encryption_key=_TEST_ENC_KEY))
|
||||
resp = client.get("/v1/user_info", headers=_service_token_header())
|
||||
|
||||
assert resp.status_code == 404
|
||||
assert "runtime_id" in resp.json()["detail"]
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["runtime_id"] is None
|
||||
|
||||
|
||||
def test_v1_user_info_returns_user(monkeypatch) -> None:
|
||||
@@ -539,7 +548,7 @@ def _make_ledger_trade(**kwargs) -> LedgerTrade:
|
||||
def _make_ledger_principal() -> TokenPrincipal:
|
||||
return TokenPrincipal(
|
||||
token_id="t1", user_id="user-1", permission="trade-write",
|
||||
token_type="service", runtime_id="rt-1"
|
||||
token_type="service", runtime_id="rt-1", daemon_id="d-1"
|
||||
)
|
||||
|
||||
|
||||
@@ -548,7 +557,9 @@ def _make_mock_repo_for_ledger(monkeypatch, trade: LedgerTrade, created: bool, f
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = _make_ledger_principal()
|
||||
if found:
|
||||
mock_repo.get.return_value = MagicMock() # portfolio exists
|
||||
mock_portfolio = MagicMock()
|
||||
mock_portfolio.managed_by_daemon_id = "d-1"
|
||||
mock_repo.get.return_value = mock_portfolio
|
||||
else:
|
||||
mock_repo.get.side_effect = Exception("not found")
|
||||
mock_repo.insert_ledger_trade.return_value = (trade, created)
|
||||
@@ -759,6 +770,7 @@ def _make_portfolio(portfolio_id: str = "p1") -> Portfolio:
|
||||
active_strategy_id=None,
|
||||
avabile_strategies=None,
|
||||
owner_user_id="user-1",
|
||||
managed_by_daemon_id="d-1",
|
||||
)
|
||||
|
||||
|
||||
@@ -786,7 +798,7 @@ def test_v1_assets_returns_asset_list(monkeypatch) -> None:
|
||||
portfolio = _make_portfolio()
|
||||
principal = TokenPrincipal(
|
||||
token_id="t1", user_id="user-1", permission="read",
|
||||
token_type="service", runtime_id="rt-1"
|
||||
token_type="service", runtime_id="rt-1", daemon_id="d-1"
|
||||
)
|
||||
|
||||
mock_repo = MagicMock()
|
||||
@@ -809,23 +821,27 @@ def test_v1_assets_returns_asset_list(monkeypatch) -> None:
|
||||
# GET /v1/user_info
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_v1_user_info_404_when_no_runtime_id(monkeypatch) -> None:
|
||||
def test_v1_user_info_null_runtime_id_returns_200_with_null(monkeypatch) -> None:
|
||||
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true")
|
||||
|
||||
principal = TokenPrincipal(
|
||||
token_id="t1", user_id="user-1", permission="read",
|
||||
token_type="service", runtime_id=None, # no runtime_id
|
||||
)
|
||||
user = User(id="user-1", username="alice", permission="read", is_active=True)
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_token_principal.return_value = principal
|
||||
mock_repo.get_user.return_value = user
|
||||
mock_repo.list_broker_credentials.return_value = []
|
||||
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \
|
||||
patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo):
|
||||
client = _make_app()
|
||||
resp = client.get("/v1/user_info", headers=_service_token_header())
|
||||
|
||||
assert resp.status_code == 404
|
||||
assert "runtime_id" in resp.json()["detail"]
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["runtime_id"] is None
|
||||
|
||||
|
||||
def test_v1_user_info_returns_user(monkeypatch) -> None:
|
||||
|
||||
Reference in New Issue
Block a user