feat: extend daemon model with daemon status tracking and list support
- Add daemon status tracking with state and metrics (idle, running, error) - Implement pfhub daemon list CLI command for viewing registered daemons - Extend auth schema and service for daemon lifecycle management - Add postgres repository queries for daemon state persistence - Expand test coverage for daemon registry and status transitions - Update rel-006 release document with daemon model verification results - Support daemon graceful shutdown and error recovery patterns
This commit is contained in:
+147
-2
@@ -10,7 +10,7 @@ from typer.testing import CliRunner
|
||||
from app.cli.main import create_app
|
||||
from app.core.config import Settings
|
||||
from app.core.security import hash_token, token_prefix
|
||||
from app.domain.models import AuthToken, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, User
|
||||
from app.domain.models import AuthToken, Daemon, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, User
|
||||
|
||||
|
||||
class FakeRepo:
|
||||
@@ -20,7 +20,9 @@ class FakeRepo:
|
||||
self.strategy_set = StrategySet(id="set-1", name="target_value", config="{}", nick="target_value:99914b932bd3")
|
||||
self._strategy_sets: dict[str, StrategySet] = {self.strategy_set.id: self.strategy_set}
|
||||
self._users: dict[str, User] = {}
|
||||
self._daemons: dict[str, Daemon] = {}
|
||||
self._user_seq = 1
|
||||
self._daemon_seq = 1
|
||||
self._comments: dict[str, PortfolioComment] = {}
|
||||
self._tags: dict[str, PortfolioTag] = {}
|
||||
self._auth_tokens: dict[str, AuthToken] = {}
|
||||
@@ -110,7 +112,60 @@ class FakeRepo:
|
||||
return next((u for u in self._users.values() if u.username == username), None)
|
||||
|
||||
def get_service_token_by_name(self, name: str) -> AuthToken | None:
|
||||
return next((t for t in self._auth_tokens.values() if t.name == name and t.token_type == "service"), None)
|
||||
return next(
|
||||
(
|
||||
t
|
||||
for t in sorted(
|
||||
self._auth_tokens.values(),
|
||||
key=lambda token: token.created_at or datetime.min.replace(tzinfo=timezone.utc),
|
||||
reverse=True,
|
||||
)
|
||||
if t.name == name and t.token_type == "service" and t.revoked_at is None
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
def create_daemon(
|
||||
self,
|
||||
owner_user_id: str,
|
||||
*,
|
||||
daemon_slug: str,
|
||||
display_name: str | None = None,
|
||||
) -> Daemon:
|
||||
daemon = Daemon(
|
||||
id=f"daemon-{self._daemon_seq}",
|
||||
owner_user_id=owner_user_id,
|
||||
daemon_slug=daemon_slug,
|
||||
display_name=display_name,
|
||||
is_active=True,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
self._daemon_seq += 1
|
||||
self._daemons[daemon.id] = daemon
|
||||
return daemon
|
||||
|
||||
def get_daemon_by_id(self, daemon_id: str) -> Daemon | None:
|
||||
return self._daemons.get(daemon_id)
|
||||
|
||||
def list_daemons_for_user(self, owner_user_id: str) -> list[Daemon]:
|
||||
return [daemon for daemon in self._daemons.values() if daemon.owner_user_id == owner_user_id]
|
||||
|
||||
def update_daemon(
|
||||
self,
|
||||
daemon_id: str,
|
||||
*,
|
||||
display_name: str | None = None,
|
||||
is_active: bool | None = None,
|
||||
) -> Daemon:
|
||||
daemon = self._daemons.get(daemon_id)
|
||||
if daemon is None:
|
||||
raise ValueError("데몬을 찾을 수 없습니다")
|
||||
if display_name is not None:
|
||||
daemon.display_name = display_name
|
||||
if is_active is not None:
|
||||
daemon.is_active = is_active
|
||||
daemon.updated_at = datetime.now(timezone.utc)
|
||||
return daemon
|
||||
|
||||
def create_auth_token(
|
||||
self,
|
||||
@@ -161,6 +216,13 @@ class FakeRepo:
|
||||
]
|
||||
return sorted(results, key=lambda t: t.created_at or datetime.min, reverse=True)
|
||||
|
||||
def rebind_service_token(self, name: str, daemon_id: str) -> AuthToken:
|
||||
token = self.get_service_token_by_name(name)
|
||||
if token is None:
|
||||
raise ValueError(f"서비스 토큰을 찾을 수 없습니다: {name}")
|
||||
token.daemon_id = daemon_id
|
||||
return token
|
||||
|
||||
def get(self, portfolio_id: str) -> Portfolio:
|
||||
if self._portfolio is None or self._portfolio.portfolio_id != portfolio_id:
|
||||
raise ValueError("포트폴리오를 찾을 수 없습니다")
|
||||
@@ -837,6 +899,7 @@ def test_cli_help_exposes_bot_token_commands() -> None:
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "issue" in result.stdout
|
||||
assert "rebind" in result.stdout
|
||||
assert "revoke" in result.stdout
|
||||
|
||||
|
||||
@@ -930,6 +993,7 @@ def test_cli_service_token_revoke_force_succeeds() -> None:
|
||||
def test_cli_service_token_list_returns_active_tokens() -> None:
|
||||
repo = FakeRepo(_portfolio())
|
||||
user = repo.create_user("alice", permission="read")
|
||||
daemon = repo.create_daemon(user.id, daemon_slug="alpha-bot")
|
||||
repo.create_auth_token(
|
||||
user.id,
|
||||
name="active-token",
|
||||
@@ -937,6 +1001,7 @@ def test_cli_service_token_list_returns_active_tokens() -> None:
|
||||
token_prefix=token_prefix("tok-1"),
|
||||
permission="read",
|
||||
token_type="service",
|
||||
daemon_id=daemon.id,
|
||||
)
|
||||
runner = build_runner(repo)
|
||||
|
||||
@@ -947,6 +1012,29 @@ def test_cli_service_token_list_returns_active_tokens() -> None:
|
||||
assert isinstance(payload, list)
|
||||
assert len(payload) == 1
|
||||
assert payload[0]["name"] == "active-token"
|
||||
assert payload[0]["daemon_id"] == daemon.id
|
||||
|
||||
|
||||
def test_cli_service_token_list_table_shows_daemon_id() -> None:
|
||||
repo = FakeRepo(_portfolio())
|
||||
user = repo.create_user("alice", permission="read")
|
||||
daemon = repo.create_daemon(user.id, daemon_slug="alpha-bot")
|
||||
repo.create_auth_token(
|
||||
user.id,
|
||||
name="active-token",
|
||||
token_hash=hash_token("tok-table"),
|
||||
token_prefix=token_prefix("tok-table"),
|
||||
permission="read",
|
||||
token_type="service",
|
||||
daemon_id=daemon.id,
|
||||
)
|
||||
runner = build_runner(repo)
|
||||
|
||||
result = runner.invoke(runner.app, ["service-token", "list"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Daemon ID" in result.stdout
|
||||
assert daemon.id in result.stdout
|
||||
|
||||
|
||||
def test_cli_service_token_list_excludes_revoked_by_default() -> None:
|
||||
@@ -1047,3 +1135,60 @@ def test_cli_service_token_list_empty_message() -> None:
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "No service tokens found." in result.stdout
|
||||
|
||||
|
||||
def test_cli_service_token_rebind_updates_daemon_id() -> None:
|
||||
repo = FakeRepo(_portfolio())
|
||||
user = repo.create_user("alice", permission="read")
|
||||
source_daemon = repo.create_daemon(user.id, daemon_slug="source-bot")
|
||||
target_daemon = repo.create_daemon(user.id, daemon_slug="target-bot")
|
||||
token = repo.create_auth_token(
|
||||
user.id,
|
||||
name="bound-token",
|
||||
token_hash=hash_token("tok-rb"),
|
||||
token_prefix=token_prefix("tok-rb"),
|
||||
permission="read",
|
||||
token_type="service",
|
||||
runtime_id=str(uuid.uuid4()),
|
||||
daemon_id=source_daemon.id,
|
||||
)
|
||||
runner = build_runner(repo)
|
||||
|
||||
result = runner.invoke(
|
||||
runner.app,
|
||||
["--format", "json", "service-token", "rebind", "bound-token", "--daemon-id", target_daemon.id],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["name"] == "bound-token"
|
||||
assert payload["previous_daemon_id"] == source_daemon.id
|
||||
assert payload["daemon_id"] == target_daemon.id
|
||||
assert repo._auth_tokens[token.id].daemon_id == target_daemon.id
|
||||
|
||||
|
||||
def test_cli_service_token_rebind_rejects_cross_user_daemon() -> None:
|
||||
repo = FakeRepo(_portfolio())
|
||||
alice = repo.create_user("alice", permission="read")
|
||||
bob = repo.create_user("bob", permission="read")
|
||||
alice_daemon = repo.create_daemon(alice.id, daemon_slug="alice-bot")
|
||||
bob_daemon = repo.create_daemon(bob.id, daemon_slug="bob-bot")
|
||||
repo.create_auth_token(
|
||||
alice.id,
|
||||
name="bound-token",
|
||||
token_hash=hash_token("tok-rb-2"),
|
||||
token_prefix=token_prefix("tok-rb-2"),
|
||||
permission="read",
|
||||
token_type="service",
|
||||
runtime_id=str(uuid.uuid4()),
|
||||
daemon_id=alice_daemon.id,
|
||||
)
|
||||
runner = build_runner(repo)
|
||||
|
||||
result = runner.invoke(
|
||||
runner.app,
|
||||
["service-token", "rebind", "bound-token", "--daemon-id", bob_daemon.id],
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "해당 토큰 사용자에 속하지 않습니다" in result.stdout
|
||||
|
||||
Reference in New Issue
Block a user