From 3bb3ff547d5b702dfc91b5bb77bf37d2e285102f Mon Sep 17 00:00:00 2001 From: Justin Ko Date: Thu, 14 May 2026 06:29:52 +0000 Subject: [PATCH] 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 --- app/cli/commands/service_tokens.py | 27 +++- app/cli/core/utils.py | 2 + app/repositories/portfolio_repository.py | 2 + app/repositories/postgres.py | 30 +++- app/schemas/auth.py | 11 ++ app/services/auth_service.py | 15 ++ ...06_20260513_daemon-model-implementation.md | 32 ++-- tests/test_cli.py | 149 +++++++++++++++++- tests/test_daemon_registry.py | 47 +++++- 9 files changed, 298 insertions(+), 17 deletions(-) diff --git a/app/cli/commands/service_tokens.py b/app/cli/commands/service_tokens.py index 8d19dc1..7a3575b 100644 --- a/app/cli/commands/service_tokens.py +++ b/app/cli/commands/service_tokens.py @@ -8,7 +8,7 @@ import typer from app.cli.core.output import mask_token_text, render_service_token_result from app.cli.core.utils import render_output -from app.schemas.auth import AuthTokenResponse, ServiceTokenIssueCliResponse, ServiceTokenRevokeCliResponse +from app.schemas.auth import AuthTokenResponse, ServiceTokenIssueCliResponse, ServiceTokenRebindCliResponse, ServiceTokenRevokeCliResponse from app.services.auth_service import AuthService token_app = typer.Typer(help="Service token lifecycle commands.") @@ -42,6 +42,7 @@ def list_service_tokens( token_prefix=t.token_prefix, permission=t.permission, token_type=t.token_type, + daemon_id=t.daemon_id, expires_at=t.expires_at, last_used_at=t.last_used_at, revoked_at=t.revoked_at, @@ -115,6 +116,30 @@ def rotate_service_token( ctx.obj.console.print(f"masked_token: {mask_token_text(issued.token)}") +@token_app.command("rebind") +def rebind_service_token( + ctx: typer.Context, + name: str = typer.Argument(..., help="Service token name to rebind."), + daemon_id: str = typer.Option(..., "--daemon-id", help="Existing daemon ID to bind to this token."), +) -> None: + """Rebind an active service token to an existing daemon.""" + + def _rebind(service: AuthService, app_context): + return service.rebind_service_token(name, daemon_id) + + previous_daemon_id, rebound = _with_service(ctx, _rebind) + response = ServiceTokenRebindCliResponse( + token_id=rebound.id, + name=name, + expires_at=rebound.expires_at, + revoked=rebound.revoked_at is not None, + runtime_id=rebound.runtime_id, + daemon_id=rebound.daemon_id, + previous_daemon_id=previous_daemon_id, + ) + render_service_token_result(ctx.obj.console, ctx.obj.output_format, response) + + @token_app.command("revoke") def revoke_service_token( ctx: typer.Context, diff --git a/app/cli/core/utils.py b/app/cli/core/utils.py index a6b436c..d9f9f08 100644 --- a/app/cli/core/utils.py +++ b/app/cli/core/utils.py @@ -168,6 +168,7 @@ def print_service_token_table(console: Console, tokens: list[AuthTokenResponse]) table.add_column("Name", style="cyan", no_wrap=True) table.add_column("Permission", no_wrap=True) table.add_column("User ID", no_wrap=True) + table.add_column("Daemon ID", no_wrap=True) table.add_column("Prefix", no_wrap=True) table.add_column("Expires At", no_wrap=True) table.add_column("Revoked At", no_wrap=True) @@ -177,6 +178,7 @@ def print_service_token_table(console: Console, tokens: list[AuthTokenResponse]) token.name, token.permission, token.user_id, + token.daemon_id or "-", token.token_prefix, token.expires_at.isoformat(timespec="seconds") if token.expires_at else "never", token.revoked_at.isoformat(timespec="seconds") if token.revoked_at else "-", diff --git a/app/repositories/portfolio_repository.py b/app/repositories/portfolio_repository.py index ae68399..71b8ec6 100644 --- a/app/repositories/portfolio_repository.py +++ b/app/repositories/portfolio_repository.py @@ -73,6 +73,8 @@ class PortfolioRepository(Protocol): def rotate_service_token(self, name: str) -> tuple[str, AuthToken]: ... + def rebind_service_token(self, name: str, daemon_id: str) -> AuthToken: ... + def create_daemon( self, owner_user_id: str, diff --git a/app/repositories/postgres.py b/app/repositories/postgres.py index 9c3c5cf..2ac6f0e 100644 --- a/app/repositories/postgres.py +++ b/app/repositories/postgres.py @@ -5,7 +5,7 @@ from __future__ import annotations import hashlib import uuid from dataclasses import dataclass -from datetime import datetime +from datetime import datetime, timezone from typing import Any import psycopg @@ -633,7 +633,7 @@ class PostgresPortfolioRepository: self.ensure_schema() with self._connect().cursor() as cur: cur.execute( - _SELECT_AUTH_TOKEN + "WHERE name = %s AND token_type = 'service'", + _SELECT_AUTH_TOKEN + "WHERE name = %s AND token_type = 'service' AND revoked_at IS NULL ORDER BY created_at DESC LIMIT 1", (name,), ) row = cur.fetchone() @@ -1399,6 +1399,32 @@ class PostgresPortfolioRepository: row = cur.fetchone() return new_raw, self._row_to_auth_token(row) + def rebind_service_token(self, name: str, daemon_id: str) -> AuthToken: + self.ensure_schema() + with self._connect().cursor() as cur: + cur.execute( + _SELECT_AUTH_TOKEN + "WHERE name = %s AND token_type = 'service' AND revoked_at IS NULL ORDER BY created_at DESC LIMIT 1", + (name,), + ) + existing_row = cur.fetchone() + if existing_row is None: + raise ValueError(f"서비스 토큰을 찾을 수 없습니다: {name}") + existing = self._row_to_auth_token(existing_row) + + cur.execute( + """ + UPDATE auth_tokens + SET daemon_id = %s + WHERE id = %s + RETURNING + id, user_id, name, token_prefix, permission, token_type, + expires_at, last_used_at, revoked_at, created_at, runtime_id, daemon_id + """, + (daemon_id, existing.id), + ) + row = cur.fetchone() + return self._row_to_auth_token(row) + @staticmethod def _row_to_daemon(row: Any) -> Daemon: if row is None: diff --git a/app/schemas/auth.py b/app/schemas/auth.py index c9a76a4..d7dc9ac 100644 --- a/app/schemas/auth.py +++ b/app/schemas/auth.py @@ -52,6 +52,7 @@ class AuthTokenResponse(BaseModel): token_prefix: str permission: Literal["read", "trade-write", "admin"] token_type: Literal["session", "client", "service"] + daemon_id: str | None = None expires_at: datetime | None = None last_used_at: datetime | None = None revoked_at: datetime | None = None @@ -97,6 +98,16 @@ class ServiceTokenIssueCliResponse(BaseModel): token: str | None = None +class ServiceTokenRebindCliResponse(BaseModel): + token_id: str + name: str + expires_at: datetime | None = None + revoked: bool + runtime_id: str | None = None + daemon_id: str | None = None + previous_daemon_id: str | None = None + + class ServiceTokenRevokeCliResponse(BaseModel): token_id: str name: str diff --git a/app/services/auth_service.py b/app/services/auth_service.py index 33594c9..712cbd2 100644 --- a/app/services/auth_service.py +++ b/app/services/auth_service.py @@ -161,6 +161,21 @@ class AuthService: raw_token, auth_token = self._repository.rotate_service_token(name) return IssuedServiceToken(token=raw_token, auth_token=auth_token) + def rebind_service_token(self, name: str, daemon_id: str) -> tuple[str | None, AuthToken]: + existing = self._repository.get_service_token_by_name(name) + if existing is None: + raise ValueError(f"서비스 토큰을 찾을 수 없습니다: {name}") + + daemon = self._repository.get_daemon_by_id(daemon_id) + if daemon is None or not daemon.is_active: + raise ValueError(f"존재하지 않거나 비활성 데몬입니다: {daemon_id}") + if daemon.owner_user_id != existing.user_id: + raise ValueError("데몬이 해당 토큰 사용자에 속하지 않습니다") + + previous_daemon_id = existing.daemon_id + rebound = self._repository.rebind_service_token(name, daemon_id) + return previous_daemon_id, rebound + def revoke_service_token(self, name: str) -> datetime: existing = self._repository.get_service_token_by_name(name) if existing is None: diff --git a/docs/releases/rel-006_20260513_daemon-model-implementation.md b/docs/releases/rel-006_20260513_daemon-model-implementation.md index e793d50..37c6a31 100644 --- a/docs/releases/rel-006_20260513_daemon-model-implementation.md +++ b/docs/releases/rel-006_20260513_daemon-model-implementation.md @@ -47,22 +47,32 @@ ## Verification Checklist +> Local validation snapshot (KST 2026-05-14 08:31): `docker-compose.local-test.yml` 기반 TimescaleDB와 로컬 app process smoke로 확인했다. 아래 체크는 actual deploy evidence가 아니라 local pre-release validation 결과다. +> +> 참고: migration 20260513_0016은 local test DB에 online apply로 검증했다. offline `alembic --sql` dry-run은 migration 내부의 `inspect(op.get_bind())` 사용 때문에 현재 그대로는 성립하지 않는다. + ### 배포 전 -- [ ] migration 20260513_0016이 target DB에 적용 가능한지 dry-run 확인 (alembic history 정상) -- [ ] NULL daemon_id service-token inventory 확인 (`SELECT COUNT(*) FROM auth_tokens WHERE daemon_id IS NULL AND token_type='service' AND revoked_at IS NULL`) -- [ ] daemon mismatch dry-run 확인 (`SELECT COUNT(*) FROM portfolios WHERE managed_by_daemon_id IS NOT NULL`) -- [ ] `pytest tests/` 128 passed / 7 failed (pre-existing) 확인 +- [x] migration 20260513_0016이 local test DB(target DB 대체 검증)에 online 적용 가능한지 확인 (`pfhub migrate-db` → head, `alembic history` 정상) +- [x] NULL daemon_id service-token inventory 확인 (`SELECT COUNT(*) FROM auth_tokens WHERE daemon_id IS NULL AND token_type='service' AND revoked_at IS NULL` → `0`) +- [x] daemon mismatch dry-run 확인 (`SELECT COUNT(*) FROM portfolios WHERE managed_by_daemon_id IS NOT NULL` → `0`) +- [x] `pytest tests/` 결과 확인 (`128 passed / 7 failed`, pre-existing CLI 실패와 일치) - [ ] release approver 승인 확보 ### 배포 후 -- [ ] `GET /health` → HTTP 200, `{"status": "ok", "service": "portfolio-hub"}` -- [ ] `pfhub_DB_ENABLED=true pfhub health-check` → exit code 0 -- [ ] `pfhub daemon create alpha-bot --user ` → daemon_id 반환 확인 -- [ ] `pfhub service-token issue test-daemon-token --user --daemon-id --no-expiry` → token 발급 확인 -- [ ] `GET /v1/assets` (daemon-bound token) → HTTP 200, daemon 소유 asset만 포함 -- [ ] `GET /v1/assets` (NULL daemon_id token) → HTTP 403 확인 -- [ ] `GET /v1/user_info` (daemon-bound token) → HTTP 200, `daemon_id` 필드 포함 +- [x] `GET /health` → HTTP 200, `{"status": "ok", "service": "portfolio-hub"}` + - local app process smoke 결과: `status=ok`, `service=portfolio-hub`, `version=0.1.0` +- [x] `pfhub_DB_ENABLED=true pfhub health-check` → exit code 0 +- [x] `pfhub daemon create alpha-bot --user ` → daemon_id 반환 확인 + - local CLI plain output에서 `Daemon(id=...)` 확인. 단, `--format json`은 datetime serialization bug로 실패한다. +- [x] `pfhub service-token issue test-daemon-token --user --daemon-id --no-expiry` → token 발급 확인 + - local CLI JSON output에서 `token_id`, `runtime_id`, `daemon_id` 반환 확인. raw token은 의도적으로 마스킹된다. +- [x] `GET /v1/assets` (daemon-bound token) → HTTP 200, daemon 소유 asset만 포함 + - local smoke fixture에서 매칭 daemon portfolio 1건만 반환되고 다른 daemon portfolio는 비노출 확인 +- [x] `GET /v1/assets` (NULL daemon_id token) → HTTP 403 확인 + - 응답 detail: `daemon_id not assigned to this token` +- [x] `GET /v1/user_info` (daemon-bound token) → HTTP 200, `daemon_id` 필드 포함 - [ ] `pfhub service-token rotate test-daemon-token --force` → 새 runtime_id, 동일 daemon_id 확인 + - local CLI 검증 실패: `idx_auth_tokens_service_name_unique` 제약으로 rotate insert가 막힌다. - [ ] journalctl/Caddy access log에 ERROR 없음 확인 ## Release Execution Evidence diff --git a/tests/test_cli.py b/tests/test_cli.py index f5e8c30..b35d50a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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 diff --git a/tests/test_daemon_registry.py b/tests/test_daemon_registry.py index 7e3db76..a417e6f 100644 --- a/tests/test_daemon_registry.py +++ b/tests/test_daemon_registry.py @@ -67,7 +67,18 @@ class _DaemonRepo: return token def get_service_token_by_name(self, name: str) -> AuthToken | None: - return next((t for t in self._tokens.values() if t.name == name and t.token_type == "service"), None) + return next( + ( + t + for t in sorted( + self._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 revoke_auth_token(self, user_id: str, token_id: str) -> None: token = self._tokens.get(token_id) @@ -142,6 +153,13 @@ class _DaemonRepo: self._tokens[new_token.id] = new_token return raw, new_token + def rebind_service_token(self, name: str, daemon_id: str) -> AuthToken: + existing = self.get_service_token_by_name(name) + if existing is None: + raise ValueError(f"서비스 토큰을 찾을 수 없습니다: {name}") + existing.daemon_id = daemon_id + return existing + def _repo_with_user(username: str = "alice") -> _DaemonRepo: repo = _DaemonRepo() @@ -284,3 +302,30 @@ def test_rotate_nonexistent_token_raises() -> None: with pytest.raises(ValueError, match="nonexistent"): service.rotate_service_token("nonexistent") + + +def test_rebind_service_token_updates_daemon_id_without_changing_runtime_id() -> None: + repo = _repo_with_user() + source_daemon = repo.create_daemon("user-1", daemon_slug="alpha-bot") + target_daemon = repo.create_daemon("user-1", daemon_slug="beta-bot") + service = _service(repo) + + issued = service.issue_service_token("my-token", "alice", daemon_id=source_daemon.id) + + previous_daemon_id, rebound = service.rebind_service_token("my-token", target_daemon.id) + + assert previous_daemon_id == source_daemon.id + assert rebound.daemon_id == target_daemon.id + assert rebound.runtime_id == issued.auth_token.runtime_id + + +def test_rebind_service_token_rejects_daemon_from_other_user() -> None: + repo = _repo_with_user() + repo._users["user-2"] = User(id="user-2", username="bob", permission="read") + source_daemon = repo.create_daemon("user-1", daemon_slug="alpha-bot") + foreign_daemon = repo.create_daemon("user-2", daemon_slug="bob-bot") + service = _service(repo) + service.issue_service_token("my-token", "alice", daemon_id=source_daemon.id) + + with pytest.raises(ValueError, match="해당 토큰 사용자에 속하지 않습니다"): + service.rebind_service_token("my-token", foreign_daemon.id)