Initial scaffold: FastAPI backend + Vue 3 frontend + Docker setup
Backend: config/db/security/logging core, SQLAlchemy models (Server, Credential, UpdateJob, UpdateLog, AuditLog, User), services (winrm, ssh, cau, audit, job_runner), REST API (auth, servers, updates, audit), Socket.io WebSocket layer. Frontend: Vue 3 + TS + Pinia + Tailwind, Views (Dashboard, Servers, Updates, Audit, Login), axios + socket.io-client, nginx prod config.
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""API route modules."""
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Audit log routes (read-only, paginated)."""
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import require_admin
|
||||
from app.core.database import get_db
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.user import User
|
||||
from app.schemas.audit import AuditLogPage
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=AuditLogPage)
|
||||
async def list_audit_logs(
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=50, ge=1, le=200),
|
||||
action: str | None = None,
|
||||
username: str | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_admin: User = Depends(require_admin),
|
||||
) -> AuditLogPage:
|
||||
stmt = select(AuditLog).order_by(AuditLog.id.desc())
|
||||
count_stmt = select(func.count(AuditLog.id))
|
||||
|
||||
if action:
|
||||
stmt = stmt.where(AuditLog.action == action)
|
||||
count_stmt = count_stmt.where(AuditLog.action == action)
|
||||
if username:
|
||||
stmt = stmt.where(AuditLog.username == username)
|
||||
count_stmt = count_stmt.where(AuditLog.username == username)
|
||||
|
||||
total = await db.scalar(count_stmt) or 0
|
||||
stmt = stmt.offset((page - 1) * page_size).limit(page_size)
|
||||
result = await db.execute(stmt)
|
||||
|
||||
return AuditLogPage(
|
||||
items=list(result.scalars().all()),
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Auth routes: login (local user or LDAP stub)."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import client_ip
|
||||
from app.core.config import get_settings
|
||||
from app.core.database import get_db
|
||||
from app.core.exceptions import UnauthorizedError
|
||||
from app.core.security import create_access_token, verify_password
|
||||
from app.models.user import User
|
||||
from app.schemas.auth import LoginRequest, TokenResponse
|
||||
from app.services.audit import AuditService
|
||||
|
||||
settings = get_settings()
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
async def login(
|
||||
payload: LoginRequest,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> TokenResponse:
|
||||
audit = AuditService(db)
|
||||
result = await db.execute(select(User).where(User.username == payload.username))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user and user.password_hash and verify_password(payload.password, user.password_hash):
|
||||
user.last_login_at = datetime.now(UTC)
|
||||
await audit.log(
|
||||
username=user.username,
|
||||
action="auth.login",
|
||||
ip_address=client_ip(request),
|
||||
)
|
||||
token = create_access_token(
|
||||
subject=user.username,
|
||||
extra_claims={"is_admin": user.is_admin},
|
||||
)
|
||||
return TokenResponse(
|
||||
access_token=token,
|
||||
expires_in=settings.jwt_access_token_expire_minutes * 60,
|
||||
)
|
||||
|
||||
# LDAP stub: when enabled, attempt bind + search here (not yet implemented)
|
||||
await audit.log(
|
||||
username=payload.username,
|
||||
action="auth.login",
|
||||
result="failure",
|
||||
ip_address=client_ip(request),
|
||||
)
|
||||
raise UnauthorizedError("Benutzername oder Passwort falsch")
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Server inventory routes."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import client_ip, get_current_user
|
||||
from app.core.database import get_db
|
||||
from app.core.exceptions import NotFoundError
|
||||
from app.models.credential import Credential
|
||||
from app.models.server import Server, ServerType
|
||||
from app.models.user import User
|
||||
from app.schemas.server import HealthCheckResult, ServerCreate, ServerRead, ServerUpdate
|
||||
from app.services.audit import AuditService
|
||||
from app.services.cau import CAUService
|
||||
from app.services.job_runner import JobRunner
|
||||
from app.services.ssh import SSHService
|
||||
from app.services.winrm import WinRMService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=list[ServerRead])
|
||||
async def list_servers(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: User = Depends(get_current_user),
|
||||
) -> list[Server]:
|
||||
result = await db.execute(select(Server).order_by(Server.name))
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.post("", response_model=ServerRead, status_code=201)
|
||||
async def create_server(
|
||||
payload: ServerCreate,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> Server:
|
||||
server = Server(**payload.model_dump())
|
||||
db.add(server)
|
||||
await db.flush()
|
||||
await AuditService(db).log(
|
||||
username=user.username,
|
||||
action="server.create",
|
||||
target=server.name,
|
||||
ip_address=client_ip(request),
|
||||
)
|
||||
return server
|
||||
|
||||
|
||||
@router.get("/{server_id}", response_model=ServerRead)
|
||||
async def get_server(
|
||||
server_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: User = Depends(get_current_user),
|
||||
) -> Server:
|
||||
server = await db.get(Server, server_id)
|
||||
if not server:
|
||||
raise NotFoundError("Server nicht gefunden")
|
||||
return server
|
||||
|
||||
|
||||
@router.patch("/{server_id}", response_model=ServerRead)
|
||||
async def update_server(
|
||||
server_id: int,
|
||||
payload: ServerUpdate,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> Server:
|
||||
server = await db.get(Server, server_id)
|
||||
if not server:
|
||||
raise NotFoundError("Server nicht gefunden")
|
||||
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||
setattr(server, field, value)
|
||||
await AuditService(db).log(
|
||||
username=user.username,
|
||||
action="server.update",
|
||||
target=server.name,
|
||||
ip_address=client_ip(request),
|
||||
)
|
||||
return server
|
||||
|
||||
|
||||
@router.delete("/{server_id}", status_code=204)
|
||||
async def delete_server(
|
||||
server_id: int,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> None:
|
||||
server = await db.get(Server, server_id)
|
||||
if not server:
|
||||
raise NotFoundError("Server nicht gefunden")
|
||||
await AuditService(db).log(
|
||||
username=user.username,
|
||||
action="server.delete",
|
||||
target=server.name,
|
||||
ip_address=client_ip(request),
|
||||
)
|
||||
await db.delete(server)
|
||||
|
||||
|
||||
@router.get("/{server_id}/health", response_model=HealthCheckResult)
|
||||
async def check_server_health(
|
||||
server_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: User = Depends(get_current_user),
|
||||
) -> HealthCheckResult:
|
||||
server = await db.get(Server, server_id)
|
||||
if not server:
|
||||
raise NotFoundError("Server nicht gefunden")
|
||||
|
||||
credential = await db.get(Credential, server.credential_id) if server.credential_id else None
|
||||
|
||||
if server.type == ServerType.LINUX:
|
||||
service = SSHService(
|
||||
server.hostname,
|
||||
port=server.port,
|
||||
credentials=JobRunner._ssh_creds(credential),
|
||||
)
|
||||
elif server.type == ServerType.CAU_CLUSTER:
|
||||
cau = CAUService(
|
||||
server.hostname,
|
||||
access_node=server.hostname,
|
||||
port=server.port,
|
||||
credentials=JobRunner._winrm_creds(credential),
|
||||
)
|
||||
ok, message = await cau.test_cluster()
|
||||
server.last_health_at = datetime.now(UTC)
|
||||
server.last_health_ok = ok
|
||||
return HealthCheckResult(
|
||||
server_id=server.id,
|
||||
ok=ok,
|
||||
message=message,
|
||||
checked_at=server.last_health_at,
|
||||
)
|
||||
else:
|
||||
service = WinRMService(
|
||||
server.hostname,
|
||||
port=server.port,
|
||||
credentials=JobRunner._winrm_creds(credential),
|
||||
)
|
||||
|
||||
started = datetime.now(UTC)
|
||||
ok, message = await service.test_connection()
|
||||
latency_ms = (datetime.now(UTC) - started).total_seconds() * 1000
|
||||
|
||||
server.last_health_at = datetime.now(UTC)
|
||||
server.last_health_ok = ok
|
||||
|
||||
return HealthCheckResult(
|
||||
server_id=server.id,
|
||||
ok=ok,
|
||||
latency_ms=round(latency_ms, 1),
|
||||
message=message,
|
||||
checked_at=server.last_health_at,
|
||||
)
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Update job routes: trigger, list, logs, cancel."""
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import client_ip, get_current_user
|
||||
from app.core.database import get_db
|
||||
from app.core.exceptions import JobNotCancellableError, NotFoundError
|
||||
from app.models.server import Server
|
||||
from app.models.update_job import JobStatus, UpdateJob, UpdateLog
|
||||
from app.models.user import User
|
||||
from app.schemas.update import JobTriggerRequest, UpdateJobRead, UpdateLogRead
|
||||
from app.services.audit import AuditService
|
||||
from app.services.job_runner import job_runner
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/trigger", response_model=UpdateJobRead, status_code=201)
|
||||
async def trigger_update(
|
||||
payload: JobTriggerRequest,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> UpdateJob:
|
||||
server = await db.get(Server, payload.server_id)
|
||||
if not server:
|
||||
raise NotFoundError("Server nicht gefunden")
|
||||
|
||||
job = UpdateJob(
|
||||
server_id=server.id,
|
||||
type=payload.type,
|
||||
started_by=user.username,
|
||||
)
|
||||
db.add(job)
|
||||
await db.flush()
|
||||
|
||||
await AuditService(db).log(
|
||||
username=user.username,
|
||||
action="update.trigger",
|
||||
target=server.name,
|
||||
details={"job_id": job.id, "type": payload.type.value},
|
||||
ip_address=client_ip(request),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
await job_runner.start(job.id)
|
||||
return job
|
||||
|
||||
|
||||
@router.get("", response_model=list[UpdateJobRead])
|
||||
async def list_jobs(
|
||||
status: JobStatus | None = None,
|
||||
limit: int = Query(default=50, le=200),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: User = Depends(get_current_user),
|
||||
) -> list[UpdateJob]:
|
||||
stmt = select(UpdateJob).order_by(UpdateJob.id.desc()).limit(limit)
|
||||
if status:
|
||||
stmt = stmt.where(UpdateJob.status == status)
|
||||
result = await db.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.get("/{job_id}", response_model=UpdateJobRead)
|
||||
async def get_job(
|
||||
job_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: User = Depends(get_current_user),
|
||||
) -> UpdateJob:
|
||||
job = await db.get(UpdateJob, job_id)
|
||||
if not job:
|
||||
raise NotFoundError("Job nicht gefunden")
|
||||
return job
|
||||
|
||||
|
||||
@router.get("/{job_id}/logs", response_model=list[UpdateLogRead])
|
||||
async def get_job_logs(
|
||||
job_id: int,
|
||||
after_id: int = 0,
|
||||
limit: int = Query(default=500, le=2000),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: User = Depends(get_current_user),
|
||||
) -> list[UpdateLog]:
|
||||
stmt = (
|
||||
select(UpdateLog)
|
||||
.where(UpdateLog.job_id == job_id, UpdateLog.id > after_id)
|
||||
.order_by(UpdateLog.id)
|
||||
.limit(limit)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.post("/{job_id}/cancel", response_model=UpdateJobRead)
|
||||
async def cancel_job(
|
||||
job_id: int,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> UpdateJob:
|
||||
job = await db.get(UpdateJob, job_id)
|
||||
if not job:
|
||||
raise NotFoundError("Job nicht gefunden")
|
||||
if job.status not in (JobStatus.PENDING, JobStatus.RUNNING):
|
||||
raise JobNotCancellableError()
|
||||
|
||||
cancelled = await job_runner.cancel(job_id)
|
||||
if not cancelled:
|
||||
job.status = JobStatus.CANCELLED
|
||||
|
||||
await AuditService(db).log(
|
||||
username=user.username,
|
||||
action="update.cancel",
|
||||
target=f"job:{job_id}",
|
||||
ip_address=client_ip(request),
|
||||
)
|
||||
return job
|
||||
|
||||
|
||||
@router.get("/stats/summary")
|
||||
async def job_stats(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: User = Depends(get_current_user),
|
||||
) -> dict:
|
||||
total = await db.scalar(select(func.count(UpdateJob.id)))
|
||||
running = await db.scalar(
|
||||
select(func.count(UpdateJob.id)).where(UpdateJob.status == JobStatus.RUNNING)
|
||||
)
|
||||
failed = await db.scalar(
|
||||
select(func.count(UpdateJob.id)).where(UpdateJob.status == JobStatus.FAILED)
|
||||
)
|
||||
return {"total": total or 0, "running": running or 0, "failed": failed or 0}
|
||||
Reference in New Issue
Block a user