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