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,85 @@
|
|||||||
|
# Insight Updater - Environment Template
|
||||||
|
# Copy to .env and fill in secrets
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# CORE APPLICATION
|
||||||
|
# =============================================================================
|
||||||
|
APP_ENV=development
|
||||||
|
SECRET_KEY=change-me-min-32-characters-random
|
||||||
|
ENCRYPTION_KEY=change-me-32-bytes-base64-encoded
|
||||||
|
JWT_ALGORITHM=RS256
|
||||||
|
JWT_PRIVATE_KEY_PATH=/app/keys/private.pem
|
||||||
|
JWT_PUBLIC_KEY_PATH=/app/keys/public.pem
|
||||||
|
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||||
|
JWT_REFRESH_TOKEN_EXPIRE_DAYS=7
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# DATABASE
|
||||||
|
# =============================================================================
|
||||||
|
# Development: SQLite (file-based, zero config)
|
||||||
|
DATABASE_URL=sqlite+aiosqlite:///./data/app.db
|
||||||
|
|
||||||
|
# Production: PostgreSQL (uncomment and configure)
|
||||||
|
# DATABASE_URL=postgresql+asyncpg://updater:secure-password@db:5432/insight_updater
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# REDIS (for Socket.io pub/sub, caching, rate limiting)
|
||||||
|
# =============================================================================
|
||||||
|
REDIS_URL=redis://redis:6379/0
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# WINRM CONFIGURATION
|
||||||
|
# =============================================================================
|
||||||
|
WINRM_TRANSPORT=ntlm # ntlm | kerberos | credssp
|
||||||
|
WINRM_CERT_VALIDATION=ignore # ignore | validate
|
||||||
|
WINRM_OPERATION_TIMEOUT=60
|
||||||
|
WINRM_READ_TIMEOUT=120
|
||||||
|
WINRM_KERBEROS_DELEGATION=true
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# SSH CONFIGURATION
|
||||||
|
# =============================================================================
|
||||||
|
SSH_TIMEOUT=30
|
||||||
|
SSH_KEY_PATH=/app/keys/ssh_host_key # optional host key for SSH server
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# LDAP / ACTIVE DIRECTORY (STUB - prepared for future implementation)
|
||||||
|
# =============================================================================
|
||||||
|
LDAP_ENABLED=false
|
||||||
|
LDAP_URI=ldaps://dc.insight.local:636
|
||||||
|
LDAP_BIND_DN=CN=svc_updater,OU=Services,DC=insight,DC=local
|
||||||
|
LDAP_BIND_PASSWORD=
|
||||||
|
LDAP_USER_SEARCH_BASE=OU=Users,DC=insight,DC=local
|
||||||
|
LDAP_USER_FILTER=(sAMAccountName={username})
|
||||||
|
LDAP_GROUP_SEARCH_BASE=OU=Groups,DC=insight,DC=local
|
||||||
|
LDAP_GROUP_FILTER=(member={user_dn})
|
||||||
|
LDAP_CA_CERT_PATH=/app/certs/ldap-ca.pem
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# FRONTEND (injected at build time via Vite)
|
||||||
|
# =============================================================================
|
||||||
|
VITE_API_URL=http://localhost:8000
|
||||||
|
VITE_WS_URL=ws://localhost:8000
|
||||||
|
VITE_APP_TITLE=Insight Updater
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# LOGGING
|
||||||
|
# =============================================================================
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
LOG_FORMAT=json # json | console
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# DOCKER / DEPLOYMENT
|
||||||
|
# =============================================================================
|
||||||
|
COMPOSE_PROJECT_NAME=insight-updater
|
||||||
|
TRAEFIK_NETWORK=traefik-public
|
||||||
|
DOMAIN=updater.insight-it.de
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# EXTERNAL SERVICES
|
||||||
|
# =============================================================================
|
||||||
|
# Vaultwarden (for CI/CD secrets)
|
||||||
|
VAULTWARDEN_URL=https://p.hartmannsche.cloud
|
||||||
|
# Gitea
|
||||||
|
GITEA_URL=https://gitea.insight-it.de
|
||||||
|
GITEA_REPO=b0rbor4d/insight-updater
|
||||||
+92
@@ -0,0 +1,92 @@
|
|||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
ENV/
|
||||||
|
|
||||||
|
# Poetry
|
||||||
|
poetry.lock
|
||||||
|
|
||||||
|
# UV
|
||||||
|
uv.lock
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.egg-info/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
data/*.db
|
||||||
|
data/*.sqlite
|
||||||
|
data/*.sqlite3
|
||||||
|
|
||||||
|
# Keys & Certificates
|
||||||
|
keys/
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
*.crt
|
||||||
|
*.p12
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# Coverage
|
||||||
|
htmlcov/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
coverage.xml
|
||||||
|
|
||||||
|
# Test artifacts
|
||||||
|
.pytest_cache/
|
||||||
|
.tox/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
|
||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
docker-compose.override.yml
|
||||||
|
|
||||||
|
# Editor
|
||||||
|
*.sublime-workspace
|
||||||
|
*.sublime-project
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
*.bak
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
# Insight Updater - Agent Orientation
|
||||||
|
|
||||||
|
## Project Purpose
|
||||||
|
Self-hosted update orchestration for Windows (CAU/WSUS) and Linux servers via WinRM/SSH. Web UI to manage inventory, trigger updates, stream live progress via WebSocket.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
```bash
|
||||||
|
# Local development
|
||||||
|
cd ~/projects/insight-updater
|
||||||
|
docker compose up -d --build
|
||||||
|
|
||||||
|
# Backend only
|
||||||
|
cd backend && pip install -e . && uvicorn app.main:app --reload
|
||||||
|
|
||||||
|
# Frontend only
|
||||||
|
cd frontend && npm install && npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||||
|
│ Frontend │────▶│ Backend │────▶│ Database │
|
||||||
|
│ (Vue 3) │ WS │ (FastAPI) │ │ (SQLite/ │
|
||||||
|
│ Port 3000 │◀─── │ Port 8000 │ │ PostgreSQL)│
|
||||||
|
└─────────────┘ └──────┬──────┘ └─────────────┘
|
||||||
|
│
|
||||||
|
┌────────────┼────────────┐
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌─────────┐ ┌─────────┐ ┌─────────┐
|
||||||
|
│ WinRM │ │ SSH │ │ CAU │
|
||||||
|
│ Service │ │ Service │ │ Service │
|
||||||
|
└─────────┘ └─────────┘ └─────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Directories
|
||||||
|
|
||||||
|
| Path | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `backend/app/api/` | FastAPI route definitions (REST + WS) |
|
||||||
|
| `backend/app/core/` | Config, security, database, logging |
|
||||||
|
| `backend/app/models/` | SQLAlchemy ORM models |
|
||||||
|
| `backend/app/schemas/` | Pydantic request/response models |
|
||||||
|
| `backend/app/services/` | Business logic: winrm, ssh, cau, audit |
|
||||||
|
| `backend/app/websocket/` | Socket.io handlers for live updates |
|
||||||
|
| `frontend/src/views/` | Page components (Dashboard, Servers, Updates, Audit) |
|
||||||
|
| `frontend/src/components/` | Reusable UI components |
|
||||||
|
| `frontend/src/stores/` | Pinia stores (auth, servers, updates) |
|
||||||
|
| `frontend/src/api/` | Axios/Socket.io client setup |
|
||||||
|
|
||||||
|
## Core Models
|
||||||
|
|
||||||
|
| Model | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `Server` | Inventory item: Windows/WinRM, Linux/SSH, CAU-Cluster |
|
||||||
|
| `Credential` | Encrypted credentials (WinRM user/pass, SSH key/pass) |
|
||||||
|
| `UpdateJob` | One update execution: server, status, started_by, started_at, finished_at |
|
||||||
|
| `UpdateLog` | Streamed log lines per job (WebSocket → DB) |
|
||||||
|
| `AuditLog` | Immutable audit trail: user, action, target, result |
|
||||||
|
| `User` | Local admin or LDAP-mapped user |
|
||||||
|
|
||||||
|
## Key Services
|
||||||
|
|
||||||
|
| Service | File | Responsibility |
|
||||||
|
|---------|------|----------------|
|
||||||
|
| `WinRMService` | `services/winrm.py` | Connect, run PS commands, stream output |
|
||||||
|
| `SSHService` | `services/ssh.py` | Connect, run commands, sudo handling |
|
||||||
|
| `CAUService` | `services/cau.py` | `Invoke-CauRun`, cluster status, node phases |
|
||||||
|
| `AuditService` | `services/audit.py` | Structured logging to DB + JSON file |
|
||||||
|
| `EncryptionService` | `core/security.py` | Fernet encrypt/decrypt credentials |
|
||||||
|
|
||||||
|
## WebSocket Events
|
||||||
|
|
||||||
|
| Event | Direction | Payload |
|
||||||
|
|-------|-----------|---------|
|
||||||
|
| `job:start` | Server→Client | `{job_id, server_id, type}` |
|
||||||
|
| `job:log` | Server→Client | `{job_id, line, timestamp, level}` |
|
||||||
|
| `job:progress` | Server→Client | `{job_id, percent, phase, node?}` |
|
||||||
|
| `job:complete` | Server→Client | `{job_id, status, duration}` |
|
||||||
|
| `job:cancel` | Client→Server | `{job_id}` |
|
||||||
|
|
||||||
|
## API Endpoints (Key)
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/api/servers` | List all servers |
|
||||||
|
| POST | `/api/servers` | Create server |
|
||||||
|
| GET | `/api/servers/{id}/health` | Test WinRM/SSH connectivity |
|
||||||
|
| POST | `/api/updates/trigger` | Start update job |
|
||||||
|
| GET | `/api/updates/{job_id}/logs` | Get job logs (REST fallback) |
|
||||||
|
| WS | `/ws/updates` | Socket.io connection |
|
||||||
|
| GET | `/api/audit` | Paginated audit log |
|
||||||
|
| POST | `/api/auth/login` | JWT login |
|
||||||
|
| GET | `/health` | Health check |
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
See `.env.example` — key ones:
|
||||||
|
- `DATABASE_URL` — SQLite (dev) or PostgreSQL (prod)
|
||||||
|
- `SECRET_KEY` — JWT signing (32+ chars)
|
||||||
|
- `ENCRYPTION_KEY` — Fernet key for credentials (32 bytes base64)
|
||||||
|
- `WINRM_TRANSPORT` — `ntlm` \| `kerberos` \| `credssp`
|
||||||
|
- `LDAP_ENABLED` — `true`/`false` (stub)
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
```bash
|
||||||
|
# Backend
|
||||||
|
cd backend && pytest -v
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
cd frontend && npm run test
|
||||||
|
|
||||||
|
# E2E (Playwright)
|
||||||
|
cd frontend && npm run test:e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
**Target**: `monitoring` (10.0.2.105)
|
||||||
|
**User**: `b0rbor4d` (sudo via Vaultwarden)
|
||||||
|
**Reverse Proxy**: Traefik (Docker labels)
|
||||||
|
**Git Remote**: `ssh://git@gitea.insight-it.de:2222/b0rbor4d/insight-updater.git`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# On monitoring host
|
||||||
|
git clone ssh://git@gitea.insight-it.de:2222/b0rbor4d/insight-updater.git
|
||||||
|
cd insight-updater
|
||||||
|
cp .env.example .env # fill secrets
|
||||||
|
docker compose -f docker-compose.prod.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Useful Commands
|
||||||
|
```bash
|
||||||
|
# DB migrations
|
||||||
|
cd backend && alembic upgrade head
|
||||||
|
|
||||||
|
# Generate keys
|
||||||
|
openssl genrsa -out keys/private.pem 2048
|
||||||
|
openssl rsa -in keys/private.pem -pubout -out keys/public.pem
|
||||||
|
|
||||||
|
# Encrypt a test credential
|
||||||
|
python -c "from app.core.security import encrypt; print(encrypt('secret'))"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
- **Language**: German for user-facing text, English for code/comments
|
||||||
|
- **Logging**: `structlog` JSON, level from `LOG_LEVEL` env
|
||||||
|
- **Errors**: Custom exceptions in `core/exceptions.py`, mapped to HTTP in `main.py`
|
||||||
|
- **Async**: All I/O async (asyncpg, asyncssh, httpx)
|
||||||
|
- **Types**: Strict mypy, Pydantic v2, SQLAlchemy 2.0
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
# Insight Updater - Project Prompt
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Build a self-hosted update orchestration tool for Windows (CAU/WSUS) and Linux servers via WinRM/SSH. Web UI to manage servers, trigger updates, monitor progress live via WebSocket.
|
||||||
|
|
||||||
|
## Target Stack
|
||||||
|
- **Backend**: FastAPI + Python 3.11+, SQLAlchemy + SQLite/PostgreSQL, structlog, python-winrm, paramiko
|
||||||
|
- **Frontend**: Vue 3 + TypeScript + Vite, Pinia, VueUse, Tailwind CSS, Socket.io client
|
||||||
|
- **Infra**: Docker Compose (backend, frontend, db, redis), Traefik labels for reverse proxy
|
||||||
|
- **CI/CD**: Gitea Actions / Woodpecker CI for build & deploy to monitoring (10.0.2.105)
|
||||||
|
|
||||||
|
## Core Features
|
||||||
|
1. **Server Inventory** - Add/edit/delete servers (Windows/WinRM, Linux/SSH, CAU-Cluster)
|
||||||
|
2. **Live Update Streaming** - WebSocket log stream with progress, status per node
|
||||||
|
3. **CAU Cluster Orchestration** - Trigger `Invoke-CauRun`, show per-node phases
|
||||||
|
4. **Linux Patch Management** - `apt/dnf/yum update` via SSH with sudo
|
||||||
|
5. **Audit Log** - Structured JSON logs: who, when, what server, outcome
|
||||||
|
5. **Health Checks** - `/health` endpoint, WinRM/SSH connectivity test
|
||||||
|
6. **LDAP-ready Auth** - JWT tokens, LDAP config schema prepared, local admin fallback
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
- No WSUS/SCCM replacement, no approval workflows
|
||||||
|
- No agent deployment (agentless WinRM/SSH only)
|
||||||
|
- No multi-tenancy / RBAC beyond admin/user
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
- Add server → see "Online/Offline", last patch date
|
||||||
|
- Click "Update" → live WebSocket log stream → final status Success/Failed
|
||||||
|
- CAU: Trigger cluster update, see per-node Pre/Post/Reboot phases
|
||||||
|
- Linux: Add SSH creds, trigger update, see apt/dnf output
|
||||||
|
- `docker compose up -d` → all healthy in <5 min on fresh VM
|
||||||
|
- Deploy to monitoring (10.0.2.105) via `git push` + CI works
|
||||||
|
- LDAP config schema exists, service stub wired, functional later
|
||||||
|
|
||||||
|
## Verification Commands
|
||||||
|
```bash
|
||||||
|
curl -f http://localhost:8000/health
|
||||||
|
curl -f http://localhost:3000/ # frontend
|
||||||
|
docker compose ps # all healthy
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment Target
|
||||||
|
- **Host**: monitoring.insight.local (10.0.2.105)
|
||||||
|
- **User**: b0rbor4d (sudo via Vaultwarden)
|
||||||
|
- **Docker**: Podman/Docker Compose v2
|
||||||
|
- **Reverse Proxy**: Traefik (labels on compose services)
|
||||||
|
- **Git Remote**: ssh://git@gitea.insight-it.de:2222/b0rbor4d/insight-updater.git
|
||||||
|
|
||||||
|
## Security
|
||||||
|
- Credentials encrypted at rest (Fernet/AES-GCM, key from env)
|
||||||
|
- WinRM: NTLM/Kerberos, HTTPS preferred, Cert validation configurable
|
||||||
|
- SSH: Key-based auth preferred, password fallback encrypted
|
||||||
|
- JWT: RS256, short expiry, refresh token rotation
|
||||||
|
- Audit log: immutable append-only (SQLite WAL / PG)
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
```
|
||||||
|
~/projects/insight-updater/
|
||||||
|
├── backend/
|
||||||
|
│ ├── app/
|
||||||
|
│ │ ├── api/ # FastAPI routes
|
||||||
|
│ │ ├── core/ # config, security, db
|
||||||
|
│ │ ├── models/ # SQLAlchemy models
|
||||||
|
│ │ ├── schemas/ # Pydantic schemas
|
||||||
|
│ │ ├── services/ # business logic (winrm, ssh, cau, audit)
|
||||||
|
│ │ ├── websocket/ # Socket.io / FastAPI WS handlers
|
||||||
|
│ │ └── main.py
|
||||||
|
│ ├── tests/
|
||||||
|
│ ├── Dockerfile
|
||||||
|
│ ├── requirements.txt
|
||||||
|
│ └── pyproject.toml
|
||||||
|
├── frontend/
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── components/
|
||||||
|
│ │ ├── views/
|
||||||
|
│ │ ├── stores/
|
||||||
|
│ │ ├── api/
|
||||||
|
│ │ └── main.ts
|
||||||
|
│ ├── Dockerfile
|
||||||
|
│ ├── package.json
|
||||||
|
│ └── vite.config.ts
|
||||||
|
├── docker-compose.yml
|
||||||
|
├── docker-compose.prod.yml
|
||||||
|
├── .env.example
|
||||||
|
├── .gitignore
|
||||||
|
├── README.md
|
||||||
|
├── AGENTS.md
|
||||||
|
└── PROMPT.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Libraries
|
||||||
|
- `fastapi`, `uvicorn`, `sqlalchemy[asyncio]`, `alembic`
|
||||||
|
- `python-winrm[kerberos]`, `paramiko`, `asyncssh`
|
||||||
|
- `python-socketio[asyncio]`, `redis`, `structlog`
|
||||||
|
- `cryptography`, `python-jose[cryptography]`, `passlib[bcrypt]`
|
||||||
|
- `pydantic-settings`, `pydantic[email]`
|
||||||
|
- `pytest`, `pytest-asyncio`, `httpx`
|
||||||
|
|
||||||
|
## Environment Variables (.env.example)
|
||||||
|
```env
|
||||||
|
# Core
|
||||||
|
APP_ENV=development
|
||||||
|
SECRET_KEY=change-me-32-chars-min
|
||||||
|
ENCRYPTION_KEY=change-me-32-chars-base64
|
||||||
|
JWT_ALGORITHM=RS256
|
||||||
|
JWT_PRIVATE_KEY_PATH=/app/keys/private.pem
|
||||||
|
JWT_PUBLIC_KEY_PATH=/app/keys/public.pem
|
||||||
|
|
||||||
|
# Database
|
||||||
|
DATABASE_URL=sqlite+aiosqlite:///./data/app.db
|
||||||
|
# DATABASE_URL=postgresql+asyncpg://user:pass@db:5432/updater
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_URL=redis://redis:6379/0
|
||||||
|
|
||||||
|
# WinRM
|
||||||
|
WINRM_TRANSPORT=ntlm
|
||||||
|
WINRM_CERT_VALIDATION=ignore
|
||||||
|
|
||||||
|
# LDAP (stub)
|
||||||
|
LDAP_ENABLED=false
|
||||||
|
LDAP_URI=ldaps://dc.insight.local:636
|
||||||
|
LDAP_BIND_DN=CN=svc_updater,OU=Services,DC=insight,DC=local
|
||||||
|
LDAP_BIND_PASSWORD=
|
||||||
|
LDAP_USER_SEARCH_BASE=OU=Users,DC=insight,DC=local
|
||||||
|
LDAP_USER_FILTER=(sAMAccountName={username})
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
VITE_API_URL=http://localhost:8000
|
||||||
|
VITE_WS_URL=ws://localhost:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development Workflow
|
||||||
|
```bash
|
||||||
|
# Local dev
|
||||||
|
cd backend && pip install -e . && uvicorn app.main:app --reload
|
||||||
|
cd frontend && npm install && npm run dev
|
||||||
|
|
||||||
|
# Docker dev
|
||||||
|
docker compose up -d --build
|
||||||
|
|
||||||
|
# Tests
|
||||||
|
cd backend && pytest
|
||||||
|
cd frontend && npm run test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Remote Deploy (monitoring)
|
||||||
|
```bash
|
||||||
|
# On monitoring host
|
||||||
|
git clone ssh://git@gitea.insight-it.de:2222/b0rbor4d/insight-updater.git
|
||||||
|
cd insight-updater
|
||||||
|
cp .env.example .env # fill secrets
|
||||||
|
docker compose -f docker-compose.prod.yml up -d --build
|
||||||
|
```
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
# Insight Updater
|
||||||
|
|
||||||
|
Self-hosted update orchestration for Windows (CAU/WSUS) and Linux servers via WinRM/SSH. Web UI to manage inventory, trigger updates, stream live progress via WebSocket.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Server Inventory** — Windows/WinRM, Linux/SSH, CAU Clusters
|
||||||
|
- **Live Updates** — WebSocket log stream with progress per node
|
||||||
|
- **CAU Support** — Trigger `Invoke-CauRun`, track per-node phases
|
||||||
|
- **Linux Patching** — `apt/dnf/yum update` via SSH with sudo
|
||||||
|
- **Audit Log** — Structured JSON: who, when, what server, outcome
|
||||||
|
- **Health Checks** — WinRM/SSH connectivity test
|
||||||
|
- **LDAP Ready** — Config schema + stub for Active Directory auth
|
||||||
|
|
||||||
|
## Quick Start (Development)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone and enter
|
||||||
|
git clone ssh://git@gitea.insight-it.de:2222/b0rbor4d/insight-updater.git
|
||||||
|
cd insight-updater
|
||||||
|
|
||||||
|
# Configure environment
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit .env with your secrets
|
||||||
|
|
||||||
|
# Start all services
|
||||||
|
docker compose up -d --build
|
||||||
|
|
||||||
|
# Access
|
||||||
|
# Frontend: http://localhost:3000
|
||||||
|
# Backend API: http://localhost:8000
|
||||||
|
# API Docs: http://localhost:8000/docs
|
||||||
|
```
|
||||||
|
|
||||||
|
## Production Deployment (monitoring.insight.local)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# On monitoring host (10.0.2.105)
|
||||||
|
git clone ssh://git@gitea.insight-it.de:2222/b0rbor4d/insight-updater.git
|
||||||
|
cd insight-updater
|
||||||
|
|
||||||
|
# Configure production environment
|
||||||
|
cp .env.example .env
|
||||||
|
# Fill in all secrets: SECRET_KEY, ENCRYPTION_KEY, DB_PASSWORD, LDAP creds, etc.
|
||||||
|
|
||||||
|
# Generate JWT keys
|
||||||
|
mkdir -p keys
|
||||||
|
openssl genrsa -out keys/private.pem 2048
|
||||||
|
openssl rsa -in keys/private.pem -pubout -out keys/public.pem
|
||||||
|
|
||||||
|
# Deploy
|
||||||
|
docker compose -f docker-compose.prod.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┐ WebSocket ┌─────────────┐
|
||||||
|
│ Frontend │ ◀─────────────▶ │ Backend │
|
||||||
|
│ (Vue 3) │ REST + WS │ (FastAPI) │
|
||||||
|
└─────────────┘ └──────┬──────┘
|
||||||
|
│
|
||||||
|
┌──────────────────┼──────────────────┐
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌───────────┐ ┌───────────┐ ┌───────────┐
|
||||||
|
│ WinRM │ │ SSH │ │ CAU │
|
||||||
|
│ Service │ │ Service │ │ Service │
|
||||||
|
└───────────┘ └───────────┘ └───────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
| Layer | Technology |
|
||||||
|
|-------|------------|
|
||||||
|
| Backend | Python 3.11+, FastAPI, SQLAlchemy 2.0, Alembic |
|
||||||
|
| Frontend | Vue 3, TypeScript, Vite, Pinia, Tailwind CSS |
|
||||||
|
| Database | SQLite (dev) / PostgreSQL (prod) |
|
||||||
|
| Cache/Queue | Redis 7 |
|
||||||
|
| Auth | JWT (RS256), LDAP stub |
|
||||||
|
| Encryption | Fernet (cryptography) |
|
||||||
|
| WebSocket | python-socketio |
|
||||||
|
| Windows | python-winrm (Kerberos/NTLM) |
|
||||||
|
| Linux | asyncssh / paramiko |
|
||||||
|
| Deploy | Docker Compose, Traefik |
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
insight-updater/
|
||||||
|
├── backend/
|
||||||
|
│ ├── app/
|
||||||
|
│ │ ├── api/ # REST routes
|
||||||
|
│ │ ├── core/ # config, security, db
|
||||||
|
│ │ ├── models/ # SQLAlchemy models
|
||||||
|
│ │ ├── schemas/ # Pydantic schemas
|
||||||
|
│ │ ├── services/ # winrm, ssh, cau, audit
|
||||||
|
│ │ ├── websocket/ # Socket.io handlers
|
||||||
|
│ │ └── main.py
|
||||||
|
│ ├── tests/
|
||||||
|
│ ├── Dockerfile
|
||||||
|
│ ├── pyproject.toml
|
||||||
|
│ └── requirements.txt
|
||||||
|
├── frontend/
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── components/
|
||||||
|
│ │ ├── views/
|
||||||
|
│ │ ├── stores/
|
||||||
|
│ │ ├── api/
|
||||||
|
│ │ └── main.ts
|
||||||
|
│ ├── Dockerfile
|
||||||
|
│ ├── nginx.conf
|
||||||
|
│ ├── package.json
|
||||||
|
│ └── vite.config.ts
|
||||||
|
├── docker-compose.yml # Development
|
||||||
|
├── docker-compose.prod.yml # Production
|
||||||
|
├── .env.example
|
||||||
|
├── .gitignore
|
||||||
|
├── AGENTS.md
|
||||||
|
├── PROMPT.md
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
Key variables (see `.env.example` for full list):
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `SECRET_KEY` | JWT signing key (32+ chars) |
|
||||||
|
| `ENCRYPTION_KEY` | Fernet key for credentials (32 bytes base64) |
|
||||||
|
| `DATABASE_URL` | SQLite (dev) or PostgreSQL (prod) |
|
||||||
|
| `WINRM_TRANSPORT` | `ntlm` \| `kerberos` \| `credssp` |
|
||||||
|
| `LDAP_ENABLED` | Enable LDAP auth stub |
|
||||||
|
| `JWT_PRIVATE_KEY_PATH` | Path to RS256 private key |
|
||||||
|
| `JWT_PUBLIC_KEY_PATH` | Path to RS256 public key |
|
||||||
|
|
||||||
|
## Development Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backend
|
||||||
|
cd backend
|
||||||
|
pip install -e .
|
||||||
|
uvicorn app.main:app --reload
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
pytest -v
|
||||||
|
|
||||||
|
# Lint
|
||||||
|
ruff check .
|
||||||
|
mypy .
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Build
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Type check
|
||||||
|
vue-tsc --noEmit
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Documentation
|
||||||
|
|
||||||
|
- Swagger UI: `http://localhost:8000/docs`
|
||||||
|
- ReDoc: `http://localhost:8000/redoc`
|
||||||
|
- WebSocket: `ws://localhost:8000/ws/updates`
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT — Insight IT
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# System deps for kerberos, cryptography, psycopg2
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
gcc \
|
||||||
|
libkrb5-dev \
|
||||||
|
libffi-dev \
|
||||||
|
libssl-dev \
|
||||||
|
curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install uv for faster pip
|
||||||
|
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
||||||
|
|
||||||
|
# Copy dependency files
|
||||||
|
COPY pyproject.toml ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN uv pip install --system --no-cache .
|
||||||
|
|
||||||
|
# Copy application
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
|
||||||
|
USER appuser
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Insight Updater Backend
|
||||||
|
|
||||||
|
FastAPI backend for server update orchestration (WinRM/SSH/CAU) with WebSocket live logs.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -e ".[dev]"
|
||||||
|
uvicorn app.main:app --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
API docs: http://localhost:8000/docs
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""Insight Updater backend application package."""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
"""API routers."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.api.routes import audit, auth, servers, updates
|
||||||
|
|
||||||
|
api_router = APIRouter(prefix="/api")
|
||||||
|
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
|
||||||
|
api_router.include_router(servers.router, prefix="/servers", tags=["servers"])
|
||||||
|
api_router.include_router(updates.router, prefix="/updates", tags=["updates"])
|
||||||
|
api_router.include_router(audit.router, prefix="/audit", tags=["audit"])
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""Shared API dependencies: current user extraction from JWT."""
|
||||||
|
|
||||||
|
from fastapi import Depends, Request
|
||||||
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.exceptions import ForbiddenError, UnauthorizedError
|
||||||
|
from app.core.security import decode_token
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
bearer_scheme = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(
|
||||||
|
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> User:
|
||||||
|
if credentials is None:
|
||||||
|
raise UnauthorizedError("Authorization header fehlt")
|
||||||
|
payload = decode_token(credentials.credentials)
|
||||||
|
username = payload.get("sub")
|
||||||
|
if not username:
|
||||||
|
raise UnauthorizedError("Token enthält keinen Benutzer")
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
result = await db.execute(select(User).where(User.username == username))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if not user or not user.is_active:
|
||||||
|
raise UnauthorizedError("Benutzer unbekannt oder deaktiviert")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def require_admin(user: User = Depends(get_current_user)) -> User:
|
||||||
|
if not user.is_admin:
|
||||||
|
raise ForbiddenError("Administratorrechte erforderlich")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def client_ip(request: Request) -> str | None:
|
||||||
|
return request.client.host if request.client else None
|
||||||
@@ -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}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Core package: config, database, security, logging, exceptions."""
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""Application configuration via pydantic-settings.
|
||||||
|
|
||||||
|
All values are read from environment variables / .env file.
|
||||||
|
See .env.example for the full list.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
"""Central application settings."""
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||||
|
|
||||||
|
# Core
|
||||||
|
app_env: str = "development"
|
||||||
|
app_name: str = "Insight Updater"
|
||||||
|
secret_key: str = "dev-secret-change-me-32-chars-min"
|
||||||
|
encryption_key: str = ""
|
||||||
|
log_level: str = "INFO"
|
||||||
|
log_format: str = "json"
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
jwt_algorithm: str = "RS256"
|
||||||
|
jwt_private_key_path: str = "keys/private.pem"
|
||||||
|
jwt_public_key_path: str = "keys/public.pem"
|
||||||
|
jwt_access_token_expire_minutes: int = 30
|
||||||
|
jwt_refresh_token_expire_days: int = 7
|
||||||
|
|
||||||
|
# Database / Redis
|
||||||
|
database_url: str = "sqlite+aiosqlite:///./data/app.db"
|
||||||
|
redis_url: str = "redis://localhost:6379/0"
|
||||||
|
|
||||||
|
# WinRM
|
||||||
|
winrm_transport: str = "ntlm"
|
||||||
|
winrm_cert_validation: str = "ignore"
|
||||||
|
winrm_operation_timeout: int = 60
|
||||||
|
winrm_read_timeout: int = 120
|
||||||
|
winrm_kerberos_delegation: bool = True
|
||||||
|
|
||||||
|
# SSH
|
||||||
|
ssh_timeout: int = 30
|
||||||
|
|
||||||
|
# LDAP (stub)
|
||||||
|
ldap_enabled: bool = False
|
||||||
|
ldap_uri: str = ""
|
||||||
|
ldap_bind_dn: str = ""
|
||||||
|
ldap_bind_password: str = ""
|
||||||
|
ldap_user_search_base: str = ""
|
||||||
|
ldap_user_filter: str = "(sAMAccountName={username})"
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
cors_origins: str = "http://localhost:3000,http://localhost:8000"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cors_origin_list(self) -> list[str]:
|
||||||
|
return [o.strip() for o in self.cors_origins.split(",") if o.strip()]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_production(self) -> bool:
|
||||||
|
return self.app_env.lower() == "production"
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
return Settings()
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Database setup: async engine, session factory, declarative base."""
|
||||||
|
|
||||||
|
from collections.abc import AsyncGenerator
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
engine = create_async_engine(
|
||||||
|
settings.database_url,
|
||||||
|
echo=settings.log_level.upper() == "DEBUG",
|
||||||
|
pool_pre_ping=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
async_session_factory = async_sessionmaker(
|
||||||
|
engine,
|
||||||
|
class_=AsyncSession,
|
||||||
|
expire_on_commit=False,
|
||||||
|
autoflush=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
"""Declarative base for all ORM models."""
|
||||||
|
|
||||||
|
|
||||||
|
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
"""FastAPI dependency yielding an async DB session."""
|
||||||
|
async with async_session_factory() as session:
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def init_db() -> None:
|
||||||
|
"""Create all tables (scaffold mode; Alembic migrations come later)."""
|
||||||
|
# Import models so they register on the metadata
|
||||||
|
from app import models # noqa: F401
|
||||||
|
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""Custom application exceptions, mapped to HTTP responses in main.py."""
|
||||||
|
|
||||||
|
|
||||||
|
class AppError(Exception):
|
||||||
|
"""Base class for application errors."""
|
||||||
|
|
||||||
|
status_code: int = 500
|
||||||
|
detail: str = "Internal server error"
|
||||||
|
|
||||||
|
def __init__(self, detail: str | None = None) -> None:
|
||||||
|
super().__init__(detail or self.detail)
|
||||||
|
if detail:
|
||||||
|
self.detail = detail
|
||||||
|
|
||||||
|
|
||||||
|
class NotFoundError(AppError):
|
||||||
|
status_code = 404
|
||||||
|
detail = "Resource not found"
|
||||||
|
|
||||||
|
|
||||||
|
class ConflictError(AppError):
|
||||||
|
status_code = 409
|
||||||
|
detail = "Resource conflict"
|
||||||
|
|
||||||
|
|
||||||
|
class UnauthorizedError(AppError):
|
||||||
|
status_code = 401
|
||||||
|
detail = "Authentication required"
|
||||||
|
|
||||||
|
|
||||||
|
class ForbiddenError(AppError):
|
||||||
|
status_code = 403
|
||||||
|
detail = "Permission denied"
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidTokenError(UnauthorizedError):
|
||||||
|
detail = "Token is invalid or expired"
|
||||||
|
|
||||||
|
|
||||||
|
class CredentialDecryptionError(AppError):
|
||||||
|
status_code = 500
|
||||||
|
detail = "Stored credential cannot be decrypted"
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectionTestError(AppError):
|
||||||
|
status_code = 502
|
||||||
|
detail = "Connection test failed"
|
||||||
|
|
||||||
|
|
||||||
|
class WinRMError(AppError):
|
||||||
|
status_code = 502
|
||||||
|
detail = "WinRM operation failed"
|
||||||
|
|
||||||
|
|
||||||
|
class SSHError(AppError):
|
||||||
|
status_code = 502
|
||||||
|
detail = "SSH operation failed"
|
||||||
|
|
||||||
|
|
||||||
|
class CAUError(AppError):
|
||||||
|
status_code = 502
|
||||||
|
detail = "Cluster-Aware Updating operation failed"
|
||||||
|
|
||||||
|
|
||||||
|
class JobNotCancellableError(ConflictError):
|
||||||
|
detail = "Job cannot be cancelled in its current state"
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""Structured logging setup with structlog."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logging() -> None:
|
||||||
|
level = getattr(logging, settings.log_level.upper(), logging.INFO)
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
format="%(message)s",
|
||||||
|
stream=sys.stdout,
|
||||||
|
level=level,
|
||||||
|
)
|
||||||
|
|
||||||
|
processors: list = [
|
||||||
|
structlog.contextvars.merge_contextvars,
|
||||||
|
structlog.processors.add_log_level,
|
||||||
|
structlog.processors.TimeStamper(fmt="iso", utc=True),
|
||||||
|
structlog.processors.StackInfoRenderer(),
|
||||||
|
structlog.processors.format_exc_info,
|
||||||
|
]
|
||||||
|
|
||||||
|
if settings.log_format == "json":
|
||||||
|
processors.append(structlog.processors.JSONRenderer())
|
||||||
|
else:
|
||||||
|
processors.append(structlog.dev.ConsoleRenderer())
|
||||||
|
|
||||||
|
structlog.configure(
|
||||||
|
processors=processors,
|
||||||
|
wrapper_class=structlog.make_filtering_bound_logger(level),
|
||||||
|
logger_factory=structlog.PrintLoggerFactory(),
|
||||||
|
cache_logger_on_first_use=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_logger(name: str) -> structlog.stdlib.BoundLogger:
|
||||||
|
return structlog.get_logger(name) # type: ignore[no-any-return]
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"""Security helpers: Fernet credential encryption, JWT issue/verify, password hashing."""
|
||||||
|
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import bcrypt
|
||||||
|
from cryptography.fernet import Fernet, InvalidToken
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.core.exceptions import CredentialDecryptionError, InvalidTokenError
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fernet encryption for stored credentials
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_fernet: Fernet | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_fernet() -> Fernet:
|
||||||
|
global _fernet
|
||||||
|
if _fernet is None:
|
||||||
|
key = settings.encryption_key
|
||||||
|
if not key:
|
||||||
|
# Dev fallback: derive a valid fernet key from SECRET_KEY
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
key = base64.urlsafe_b64encode(
|
||||||
|
hashlib.sha256(settings.secret_key.encode()).digest()
|
||||||
|
).decode()
|
||||||
|
_fernet = Fernet(key.encode() if isinstance(key, str) else key)
|
||||||
|
return _fernet
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt(plaintext: str) -> str:
|
||||||
|
"""Encrypt a secret for at-rest storage."""
|
||||||
|
return _get_fernet().encrypt(plaintext.encode()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt(token: str) -> str:
|
||||||
|
"""Decrypt a stored secret. Raises CredentialDecryptionError on failure."""
|
||||||
|
try:
|
||||||
|
return _get_fernet().decrypt(token.encode()).decode()
|
||||||
|
except InvalidToken as exc:
|
||||||
|
raise CredentialDecryptionError("Stored credential cannot be decrypted") from exc
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Password hashing
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
# bcrypt hard limit: 72 bytes
|
||||||
|
return bcrypt.hashpw(password.encode()[:72], bcrypt.gensalt()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(plain: str, hashed: str) -> bool:
|
||||||
|
try:
|
||||||
|
return bcrypt.checkpw(plain.encode()[:72], hashed.encode())
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# JWT (RS256 with key files, HS256 fallback for dev without keys)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _read_key(path: str) -> str | None:
|
||||||
|
p = Path(path)
|
||||||
|
return p.read_text() if p.exists() else None
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(subject: str, extra_claims: dict[str, Any] | None = None) -> str:
|
||||||
|
expire = datetime.now(UTC) + timedelta(minutes=settings.jwt_access_token_expire_minutes)
|
||||||
|
claims: dict[str, Any] = {"sub": subject, "exp": expire, "type": "access"}
|
||||||
|
if extra_claims:
|
||||||
|
claims.update(extra_claims)
|
||||||
|
|
||||||
|
private_key = _read_key(settings.jwt_private_key_path)
|
||||||
|
if private_key and settings.jwt_algorithm == "RS256":
|
||||||
|
return jwt.encode(claims, private_key, algorithm="RS256")
|
||||||
|
# Dev fallback when no key pair exists yet
|
||||||
|
return jwt.encode(claims, settings.secret_key, algorithm="HS256")
|
||||||
|
|
||||||
|
|
||||||
|
def decode_token(token: str) -> dict[str, Any]:
|
||||||
|
"""Decode and validate a JWT. Raises InvalidTokenError on failure."""
|
||||||
|
try:
|
||||||
|
public_key = _read_key(settings.jwt_public_key_path)
|
||||||
|
if public_key and settings.jwt_algorithm == "RS256":
|
||||||
|
return jwt.decode(token, public_key, algorithms=["RS256"]) # type: ignore[no-any-return]
|
||||||
|
return jwt.decode(token, settings.secret_key, algorithms=["HS256"]) # type: ignore[no-any-return]
|
||||||
|
except JWTError as exc:
|
||||||
|
raise InvalidTokenError("Token is invalid or expired") from exc
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"""FastAPI application entrypoint.
|
||||||
|
|
||||||
|
Mounts:
|
||||||
|
- REST API under /api
|
||||||
|
- Socket.io under /socket.io (path) -> frontend connects to ws://host/socket.io
|
||||||
|
- /health liveness probe
|
||||||
|
"""
|
||||||
|
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
import socketio
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from app.api import api_router
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.core.database import init_db
|
||||||
|
from app.core.exceptions import AppError
|
||||||
|
from app.core.logging import get_logger, setup_logging
|
||||||
|
from app.websocket import sio
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI): # type: ignore[no-untyped-def]
|
||||||
|
setup_logging()
|
||||||
|
logger.info("app.starting", env=settings.app_env)
|
||||||
|
|
||||||
|
await init_db()
|
||||||
|
await _seed_default_admin()
|
||||||
|
|
||||||
|
# Attach Redis manager for Socket.io pub/sub (optional in dev)
|
||||||
|
try:
|
||||||
|
from socketio import AsyncRedisManager
|
||||||
|
|
||||||
|
sio.manager = AsyncRedisManager(settings.redis_url)
|
||||||
|
logger.info("ws.redis_manager_attached", url=settings.redis_url)
|
||||||
|
except Exception as exc: # noqa: BLE001 - Redis optional for scaffold
|
||||||
|
logger.warning("ws.redis_unavailable", error=str(exc))
|
||||||
|
|
||||||
|
yield
|
||||||
|
|
||||||
|
logger.info("app.stopping")
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_default_admin() -> None:
|
||||||
|
"""Create the initial admin user if no users exist (dev bootstrap).
|
||||||
|
|
||||||
|
Password comes from ADMIN_INITIAL_PASSWORD env, default 'admin' (dev only).
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
|
from app.core.database import async_session_factory
|
||||||
|
from app.core.security import hash_password
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
async with async_session_factory() as db:
|
||||||
|
count = await db.scalar(select(func.count(User.id)))
|
||||||
|
if count and count > 0:
|
||||||
|
return
|
||||||
|
password = os.environ.get("ADMIN_INITIAL_PASSWORD", "admin")
|
||||||
|
db.add(
|
||||||
|
User(
|
||||||
|
username="admin",
|
||||||
|
email=None,
|
||||||
|
full_name="Administrator",
|
||||||
|
password_hash=hash_password(password),
|
||||||
|
is_admin=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
logger.info("app.default_admin_created", username="admin")
|
||||||
|
|
||||||
|
|
||||||
|
fastapi_app = FastAPI(
|
||||||
|
title=settings.app_name,
|
||||||
|
version="0.1.0",
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
|
||||||
|
fastapi_app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=settings.cors_origin_list,
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@fastapi_app.exception_handler(AppError)
|
||||||
|
async def app_error_handler(request: Request, exc: AppError) -> JSONResponse: # noqa: ARG001
|
||||||
|
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
|
||||||
|
|
||||||
|
|
||||||
|
@fastapi_app.get("/health")
|
||||||
|
async def health() -> dict:
|
||||||
|
return {"status": "ok", "env": settings.app_env, "version": "0.1.0"}
|
||||||
|
|
||||||
|
|
||||||
|
fastapi_app.include_router(api_router)
|
||||||
|
|
||||||
|
# Combined ASGI app: FastAPI + Socket.io
|
||||||
|
app = socketio.ASGIApp(sio, other_asgi_app=fastapi_app, socketio_path="socket.io")
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
"""SQLAlchemy ORM models."""
|
||||||
|
|
||||||
|
from app.models.audit_log import AuditLog
|
||||||
|
from app.models.credential import Credential
|
||||||
|
from app.models.server import Server
|
||||||
|
from app.models.update_job import UpdateJob, UpdateLog
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AuditLog",
|
||||||
|
"Credential",
|
||||||
|
"Server",
|
||||||
|
"UpdateJob",
|
||||||
|
"UpdateLog",
|
||||||
|
"User",
|
||||||
|
]
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"""Immutable audit trail model."""
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, String, Text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class AuditLog(Base):
|
||||||
|
__tablename__ = "audit_logs"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
timestamp: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), default=lambda: datetime.now(UTC), index=True
|
||||||
|
)
|
||||||
|
username: Mapped[str] = mapped_column(String(255), index=True)
|
||||||
|
action: Mapped[str] = mapped_column(String(100), index=True) # e.g. server.create
|
||||||
|
target: Mapped[str | None] = mapped_column(String(255), nullable=True) # e.g. server name
|
||||||
|
result: Mapped[str] = mapped_column(String(50), default="success") # success | failure
|
||||||
|
details: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON blob
|
||||||
|
ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True)
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""Credential model - secrets stored Fernet-encrypted."""
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, Enum, String, Text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
import enum
|
||||||
|
|
||||||
|
|
||||||
|
class CredentialType(str, enum.Enum):
|
||||||
|
WINRM_USERPASS = "winrm_userpass"
|
||||||
|
SSH_USERPASS = "ssh_userpass"
|
||||||
|
SSH_KEY = "ssh_key"
|
||||||
|
|
||||||
|
|
||||||
|
class Credential(Base):
|
||||||
|
__tablename__ = "credentials"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), unique=True)
|
||||||
|
type: Mapped[CredentialType] = mapped_column(Enum(CredentialType))
|
||||||
|
|
||||||
|
username: Mapped[str] = mapped_column(String(255))
|
||||||
|
# Encrypted at rest via core.security.encrypt()
|
||||||
|
password_encrypted: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
private_key_encrypted: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
key_passphrase_encrypted: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), default=lambda: datetime.now(UTC)
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=lambda: datetime.now(UTC),
|
||||||
|
onupdate=lambda: datetime.now(UTC),
|
||||||
|
)
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""Server inventory model."""
|
||||||
|
|
||||||
|
import enum
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, Enum, ForeignKey, String, Text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class ServerType(str, enum.Enum):
|
||||||
|
WINDOWS = "windows" # WinRM
|
||||||
|
LINUX = "linux" # SSH
|
||||||
|
CAU_CLUSTER = "cau_cluster" # Cluster-Aware Updating
|
||||||
|
|
||||||
|
|
||||||
|
class Server(Base):
|
||||||
|
__tablename__ = "servers"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
||||||
|
hostname: Mapped[str] = mapped_column(String(255))
|
||||||
|
port: Mapped[int] = mapped_column(default=5985)
|
||||||
|
type: Mapped[ServerType] = mapped_column(Enum(ServerType), default=ServerType.WINDOWS)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
tags: Mapped[str | None] = mapped_column(String(500), nullable=True) # comma-separated
|
||||||
|
|
||||||
|
credential_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("credentials.id"), nullable=True
|
||||||
|
)
|
||||||
|
credential: Mapped["Credential | None"] = relationship(lazy="selectin") # noqa: F821
|
||||||
|
|
||||||
|
last_health_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
last_health_ok: Mapped[bool | None] = mapped_column(nullable=True)
|
||||||
|
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), default=lambda: datetime.now(UTC)
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=lambda: datetime.now(UTC),
|
||||||
|
onupdate=lambda: datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
jobs: Mapped[list["UpdateJob"]] = relationship( # noqa: F821
|
||||||
|
back_populates="server", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""Update job + streamed log line models."""
|
||||||
|
|
||||||
|
import enum
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, Enum, ForeignKey, Integer, String, Text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class JobStatus(str, enum.Enum):
|
||||||
|
PENDING = "pending"
|
||||||
|
RUNNING = "running"
|
||||||
|
SUCCESS = "success"
|
||||||
|
FAILED = "failed"
|
||||||
|
CANCELLED = "cancelled"
|
||||||
|
|
||||||
|
|
||||||
|
class JobType(str, enum.Enum):
|
||||||
|
WINDOWS_UPDATE = "windows_update"
|
||||||
|
LINUX_UPDATE = "linux_update"
|
||||||
|
CAU_RUN = "cau_run"
|
||||||
|
HEALTH_CHECK = "health_check"
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateJob(Base):
|
||||||
|
__tablename__ = "update_jobs"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
server_id: Mapped[int] = mapped_column(ForeignKey("servers.id"), index=True)
|
||||||
|
server: Mapped["Server"] = relationship(back_populates="jobs", lazy="selectin") # noqa: F821
|
||||||
|
|
||||||
|
type: Mapped[JobType] = mapped_column(Enum(JobType))
|
||||||
|
status: Mapped[JobStatus] = mapped_column(Enum(JobStatus), default=JobStatus.PENDING, index=True)
|
||||||
|
progress_percent: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
current_phase: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
|
||||||
|
started_by: Mapped[str] = mapped_column(String(255)) # username
|
||||||
|
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), default=lambda: datetime.now(UTC)
|
||||||
|
)
|
||||||
|
|
||||||
|
logs: Mapped[list["UpdateLog"]] = relationship(
|
||||||
|
back_populates="job", cascade="all, delete-orphan", order_by="UpdateLog.id"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateLog(Base):
|
||||||
|
__tablename__ = "update_logs"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
job_id: Mapped[int] = mapped_column(ForeignKey("update_jobs.id"), index=True)
|
||||||
|
job: Mapped[UpdateJob] = relationship(back_populates="logs")
|
||||||
|
|
||||||
|
timestamp: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), default=lambda: datetime.now(UTC)
|
||||||
|
)
|
||||||
|
level: Mapped[str] = mapped_column(String(20), default="info")
|
||||||
|
line: Mapped[str] = mapped_column(Text)
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""User model - local admins or LDAP-mapped users."""
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
username: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
||||||
|
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
full_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
|
||||||
|
password_hash: Mapped[str | None] = mapped_column(String(255), nullable=True) # null = LDAP only
|
||||||
|
is_ldap: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), default=lambda: datetime.now(UTC)
|
||||||
|
)
|
||||||
|
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
"""Pydantic schemas (request/response)."""
|
||||||
|
|
||||||
|
from app.schemas.audit import AuditLogRead
|
||||||
|
from app.schemas.auth import LoginRequest, TokenResponse
|
||||||
|
from app.schemas.server import ServerCreate, ServerRead, ServerUpdate
|
||||||
|
from app.schemas.update import JobTriggerRequest, UpdateJobRead, UpdateLogRead
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AuditLogRead",
|
||||||
|
"LoginRequest",
|
||||||
|
"TokenResponse",
|
||||||
|
"ServerCreate",
|
||||||
|
"ServerRead",
|
||||||
|
"ServerUpdate",
|
||||||
|
"JobTriggerRequest",
|
||||||
|
"UpdateJobRead",
|
||||||
|
"UpdateLogRead",
|
||||||
|
]
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""Audit log schemas."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class AuditLogRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
timestamp: datetime
|
||||||
|
username: str
|
||||||
|
action: str
|
||||||
|
target: str | None
|
||||||
|
result: str
|
||||||
|
details: str | None
|
||||||
|
ip_address: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class AuditLogPage(BaseModel):
|
||||||
|
items: list[AuditLogRead]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"""Auth schemas."""
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
username: str = Field(min_length=1)
|
||||||
|
password: str = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class TokenResponse(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
expires_in: int # seconds
|
||||||
|
|
||||||
|
|
||||||
|
class UserRead(BaseModel):
|
||||||
|
id: int
|
||||||
|
username: str
|
||||||
|
email: str | None
|
||||||
|
full_name: str | None
|
||||||
|
is_admin: bool
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""Server schemas."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from app.models.server import ServerType
|
||||||
|
|
||||||
|
|
||||||
|
class ServerCreate(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=255)
|
||||||
|
hostname: str = Field(min_length=1, max_length=255)
|
||||||
|
port: int = 5985
|
||||||
|
type: ServerType = ServerType.WINDOWS
|
||||||
|
description: str | None = None
|
||||||
|
tags: str | None = None
|
||||||
|
credential_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ServerUpdate(BaseModel):
|
||||||
|
name: str | None = None
|
||||||
|
hostname: str | None = None
|
||||||
|
port: int | None = None
|
||||||
|
type: ServerType | None = None
|
||||||
|
description: str | None = None
|
||||||
|
tags: str | None = None
|
||||||
|
credential_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ServerRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
hostname: str
|
||||||
|
port: int
|
||||||
|
type: ServerType
|
||||||
|
description: str | None
|
||||||
|
tags: str | None
|
||||||
|
credential_id: int | None
|
||||||
|
last_health_at: datetime | None
|
||||||
|
last_health_ok: bool | None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class HealthCheckResult(BaseModel):
|
||||||
|
server_id: int
|
||||||
|
ok: bool
|
||||||
|
latency_ms: float | None = None
|
||||||
|
message: str
|
||||||
|
checked_at: datetime
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""Update job schemas."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
from app.models.update_job import JobStatus, JobType
|
||||||
|
|
||||||
|
|
||||||
|
class JobTriggerRequest(BaseModel):
|
||||||
|
server_id: int
|
||||||
|
type: JobType
|
||||||
|
# CAU-specific options
|
||||||
|
cluster_name: str | None = None
|
||||||
|
# Linux-specific options
|
||||||
|
reboot_if_required: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateJobRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
server_id: int
|
||||||
|
type: JobType
|
||||||
|
status: JobStatus
|
||||||
|
progress_percent: int
|
||||||
|
current_phase: str | None
|
||||||
|
started_by: str
|
||||||
|
started_at: datetime | None
|
||||||
|
finished_at: datetime | None
|
||||||
|
error: str | None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateLogRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
job_id: int
|
||||||
|
timestamp: datetime
|
||||||
|
level: str
|
||||||
|
line: str
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Business logic services: winrm, ssh, cau, audit."""
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Audit service: write structured, immutable audit entries to DB."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.logging import get_logger
|
||||||
|
from app.models.audit_log import AuditLog
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AuditService:
|
||||||
|
"""Persists audit events. Every mutating API action should call this."""
|
||||||
|
|
||||||
|
def __init__(self, db: AsyncSession) -> None:
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
async def log(
|
||||||
|
self,
|
||||||
|
username: str,
|
||||||
|
action: str,
|
||||||
|
target: str | None = None,
|
||||||
|
result: str = "success",
|
||||||
|
details: dict[str, Any] | None = None,
|
||||||
|
ip_address: str | None = None,
|
||||||
|
) -> AuditLog:
|
||||||
|
entry = AuditLog(
|
||||||
|
username=username,
|
||||||
|
action=action,
|
||||||
|
target=target,
|
||||||
|
result=result,
|
||||||
|
details=json.dumps(details) if details else None,
|
||||||
|
ip_address=ip_address,
|
||||||
|
)
|
||||||
|
self.db.add(entry)
|
||||||
|
await self.db.flush()
|
||||||
|
logger.info(
|
||||||
|
"audit",
|
||||||
|
username=username,
|
||||||
|
action=action,
|
||||||
|
target=target,
|
||||||
|
result=result,
|
||||||
|
)
|
||||||
|
return entry
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""CAU service: Cluster-Aware Updating via PowerShell remoting (WinRM)."""
|
||||||
|
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
|
||||||
|
from app.core.exceptions import CAUError
|
||||||
|
from app.core.logging import get_logger
|
||||||
|
from app.services.winrm import WinRMCredentials, WinRMService
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class CAUService:
|
||||||
|
"""Orchestrates Invoke-CauRun against a Windows failover cluster."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
cluster_name: str,
|
||||||
|
access_node: str,
|
||||||
|
port: int = 5985,
|
||||||
|
credentials: WinRMCredentials | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.cluster_name = cluster_name
|
||||||
|
self.winrm = WinRMService(access_node, port=port, credentials=credentials)
|
||||||
|
|
||||||
|
async def get_cluster_nodes(self) -> list[dict[str, str]]:
|
||||||
|
"""List cluster nodes and their state."""
|
||||||
|
script = f"""
|
||||||
|
Import-Module FailoverClusters
|
||||||
|
Get-ClusterNode -Cluster {self.cluster_name} |
|
||||||
|
Select-Object Name, State |
|
||||||
|
ForEach-Object {{ "$($_.Name)|$($_.State)" }}
|
||||||
|
"""
|
||||||
|
output = await self.winrm.run_powershell(script)
|
||||||
|
nodes = []
|
||||||
|
for line in output.splitlines():
|
||||||
|
if "|" in line:
|
||||||
|
name, state = line.split("|", 1)
|
||||||
|
nodes.append({"name": name.strip(), "state": state.strip()})
|
||||||
|
return nodes
|
||||||
|
|
||||||
|
async def get_cau_status(self) -> str:
|
||||||
|
"""Return the last CAU run summary."""
|
||||||
|
script = f"""
|
||||||
|
Import-Module ClusterAwareUpdating
|
||||||
|
Get-CauRun -ClusterName {self.cluster_name} |
|
||||||
|
Select-Object -First 1 |
|
||||||
|
Format-List | Out-String
|
||||||
|
"""
|
||||||
|
return await self.winrm.run_powershell(script)
|
||||||
|
|
||||||
|
async def test_cluster(self) -> tuple[bool, str]:
|
||||||
|
"""Verify the cluster is reachable and CAU module present."""
|
||||||
|
try:
|
||||||
|
nodes = await self.get_cluster_nodes()
|
||||||
|
if not nodes:
|
||||||
|
return False, "Keine Cluster-Knoten gefunden"
|
||||||
|
return True, f"Cluster OK — {len(nodes)} Knoten"
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return False, str(exc)
|
||||||
|
|
||||||
|
async def invoke_cau_run(self) -> AsyncIterator[str]:
|
||||||
|
"""Start a CAU run and stream per-node progress lines.
|
||||||
|
|
||||||
|
Scaffold implementation: starts Invoke-CauRun and polls Get-CauReport.
|
||||||
|
TODO: true live streaming of per-node phases via event log polling.
|
||||||
|
"""
|
||||||
|
script = f"""
|
||||||
|
Import-Module ClusterAwareUpdating
|
||||||
|
Invoke-CauRun -ClusterName {self.cluster_name} `
|
||||||
|
-Force -Confirm:$false -WaitForCompletion |
|
||||||
|
Out-String
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async for line in self.winrm.stream_powershell(script):
|
||||||
|
yield line
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
raise CAUError(f"CAU-Lauf fehlgeschlagen: {exc}") from exc
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
"""Job runner: executes update jobs in background tasks, streams logs to WS + DB."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from app.core.database import async_session_factory
|
||||||
|
from app.core.logging import get_logger
|
||||||
|
from app.models.credential import Credential, CredentialType
|
||||||
|
from app.models.server import Server, ServerType
|
||||||
|
from app.models.update_job import JobStatus, UpdateJob, UpdateLog
|
||||||
|
from app.services.cau import CAUService
|
||||||
|
from app.services.ssh import SSHCredentials, SSHService
|
||||||
|
from app.services.winrm import WinRMCredentials, WinRMService
|
||||||
|
from app.websocket import handlers as ws
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class JobRunner:
|
||||||
|
"""Manages asyncio tasks for running update jobs."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._tasks: dict[int, asyncio.Task] = {} # type: ignore[type-arg]
|
||||||
|
|
||||||
|
async def start(self, job_id: int) -> None:
|
||||||
|
task = asyncio.create_task(self._run(job_id), name=f"job-{job_id}")
|
||||||
|
self._tasks[job_id] = task
|
||||||
|
task.add_done_callback(lambda _t: self._tasks.pop(job_id, None))
|
||||||
|
|
||||||
|
async def cancel(self, job_id: int) -> bool:
|
||||||
|
task = self._tasks.get(job_id)
|
||||||
|
if not task:
|
||||||
|
return False
|
||||||
|
task.cancel()
|
||||||
|
return True
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _run(self, job_id: int) -> None:
|
||||||
|
async with async_session_factory() as db:
|
||||||
|
job = await db.get(UpdateJob, job_id)
|
||||||
|
if not job:
|
||||||
|
logger.error("job.not_found", job_id=job_id)
|
||||||
|
return
|
||||||
|
server = await db.get(Server, job.server_id)
|
||||||
|
if not server:
|
||||||
|
await self._finish(db, job, JobStatus.FAILED, "Server nicht gefunden")
|
||||||
|
return
|
||||||
|
|
||||||
|
job.status = JobStatus.RUNNING
|
||||||
|
job.started_at = datetime.now(UTC)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
await ws.emit_job_start(job.id, server.id, job.type.value)
|
||||||
|
started = datetime.now(UTC)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._dispatch(db, job, server)
|
||||||
|
await self._finish(db, job, JobStatus.SUCCESS, None, started)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
await self._finish(db, job, JobStatus.CANCELLED, "Vom Benutzer abgebrochen", started)
|
||||||
|
raise
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.error("job.failed", job_id=job_id, error=str(exc))
|
||||||
|
await self._finish(db, job, JobStatus.FAILED, str(exc), started)
|
||||||
|
|
||||||
|
async def _dispatch(self, db, job: UpdateJob, server: Server) -> None: # type: ignore[no-untyped-def]
|
||||||
|
credential = await db.get(Credential, server.credential_id) if server.credential_id else None
|
||||||
|
|
||||||
|
if server.type == ServerType.LINUX:
|
||||||
|
creds = self._ssh_creds(credential)
|
||||||
|
service = SSHService(server.hostname, port=server.port, credentials=creds)
|
||||||
|
async for line in service.stream_updates():
|
||||||
|
await self._log(db, job, line)
|
||||||
|
|
||||||
|
elif server.type == ServerType.WINDOWS:
|
||||||
|
creds = self._winrm_creds(credential)
|
||||||
|
service = WinRMService(server.hostname, port=server.port, credentials=creds)
|
||||||
|
async for line in service.install_updates():
|
||||||
|
await self._log(db, job, line)
|
||||||
|
|
||||||
|
elif server.type == ServerType.CAU_CLUSTER:
|
||||||
|
creds = self._winrm_creds(credential)
|
||||||
|
service = CAUService(server.hostname, access_node=server.hostname, port=server.port, credentials=creds)
|
||||||
|
async for line in service.invoke_cau_run():
|
||||||
|
await self._log(db, job, line)
|
||||||
|
|
||||||
|
async def _log(self, db, job: UpdateJob, line: str, level: str = "info") -> None: # type: ignore[no-untyped-def]
|
||||||
|
db.add(UpdateLog(job_id=job.id, level=level, line=line))
|
||||||
|
await db.commit()
|
||||||
|
await ws.emit_job_log(job.id, line, level)
|
||||||
|
|
||||||
|
async def _finish( # type: ignore[no-untyped-def]
|
||||||
|
self, db, job: UpdateJob, status: JobStatus, error: str | None, started: datetime | None = None
|
||||||
|
) -> None:
|
||||||
|
job.status = status
|
||||||
|
job.error = error
|
||||||
|
job.finished_at = datetime.now(UTC)
|
||||||
|
if status == JobStatus.SUCCESS:
|
||||||
|
job.progress_percent = 100
|
||||||
|
await db.commit()
|
||||||
|
duration = (
|
||||||
|
(job.finished_at - started).total_seconds() if started else None
|
||||||
|
)
|
||||||
|
await ws.emit_job_complete(job.id, status.value, duration)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _winrm_creds(credential: Credential | None) -> WinRMCredentials | None:
|
||||||
|
if not credential or credential.type != CredentialType.WINRM_USERPASS:
|
||||||
|
return None
|
||||||
|
from app.core.security import decrypt
|
||||||
|
|
||||||
|
return WinRMCredentials(
|
||||||
|
username=credential.username,
|
||||||
|
password=decrypt(credential.password_encrypted or ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _ssh_creds(credential: Credential | None) -> SSHCredentials | None:
|
||||||
|
if not credential:
|
||||||
|
return None
|
||||||
|
from app.core.security import decrypt
|
||||||
|
|
||||||
|
return SSHCredentials(
|
||||||
|
username=credential.username,
|
||||||
|
password=decrypt(credential.password_encrypted) if credential.password_encrypted else None,
|
||||||
|
private_key=decrypt(credential.private_key_encrypted)
|
||||||
|
if credential.private_key_encrypted
|
||||||
|
else None,
|
||||||
|
passphrase=decrypt(credential.key_passphrase_encrypted)
|
||||||
|
if credential.key_passphrase_encrypted
|
||||||
|
else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
job_runner = JobRunner()
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
"""SSH service: connect to Linux hosts via asyncssh, run updates with sudo."""
|
||||||
|
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.core.exceptions import SSHError
|
||||||
|
from app.core.logging import get_logger
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SSHCredentials:
|
||||||
|
username: str
|
||||||
|
password: str | None = None
|
||||||
|
private_key: str | None = None
|
||||||
|
passphrase: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SSHService:
|
||||||
|
"""Async SSH operations via asyncssh."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
hostname: str,
|
||||||
|
port: int = 22,
|
||||||
|
credentials: SSHCredentials | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.hostname = hostname
|
||||||
|
self.port = port
|
||||||
|
self.credentials = credentials
|
||||||
|
|
||||||
|
def _connect_kwargs(self) -> dict:
|
||||||
|
kwargs: dict = {
|
||||||
|
"host": self.hostname,
|
||||||
|
"port": self.port,
|
||||||
|
"known_hosts": None, # scaffold: accept any host key
|
||||||
|
"connect_timeout": settings.ssh_timeout,
|
||||||
|
}
|
||||||
|
if self.credentials:
|
||||||
|
kwargs["username"] = self.credentials.username
|
||||||
|
if self.credentials.password:
|
||||||
|
kwargs["password"] = self.credentials.password
|
||||||
|
if self.credentials.private_key:
|
||||||
|
kwargs["client_keys"] = [
|
||||||
|
__import__("asyncssh").import_private_key(
|
||||||
|
self.credentials.private_key,
|
||||||
|
passphrase=self.credentials.passphrase,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
return kwargs
|
||||||
|
|
||||||
|
async def test_connection(self) -> tuple[bool, str]:
|
||||||
|
import asyncssh
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with asyncssh.connect(**self._connect_kwargs()) as conn:
|
||||||
|
result = await conn.run("hostname", check=True)
|
||||||
|
return True, f"Verbunden mit {result.stdout.strip()}" # type: ignore[union-attr]
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return False, str(exc)
|
||||||
|
|
||||||
|
async def run_command(self, command: str, sudo: bool = False) -> str:
|
||||||
|
"""Run a command, optionally via sudo -S with the stored password."""
|
||||||
|
import asyncssh
|
||||||
|
|
||||||
|
if sudo:
|
||||||
|
password = (self.credentials.password if self.credentials else None) or ""
|
||||||
|
command = f"echo '{password}' | sudo -S {command}"
|
||||||
|
|
||||||
|
async with asyncssh.connect(**self._connect_kwargs()) as conn:
|
||||||
|
result = await conn.run(command, check=False)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise SSHError(
|
||||||
|
f"Command failed (exit {result.returncode}): {str(result.stderr).strip()}"
|
||||||
|
)
|
||||||
|
return str(result.stdout)
|
||||||
|
|
||||||
|
async def detect_package_manager(self) -> str:
|
||||||
|
"""Detect apt, dnf, or yum on the target."""
|
||||||
|
import asyncssh
|
||||||
|
|
||||||
|
async with asyncssh.connect(**self._connect_kwargs()) as conn:
|
||||||
|
for pm in ("apt-get", "dnf", "yum"):
|
||||||
|
result = await conn.run(f"command -v {pm}", check=False)
|
||||||
|
if result.returncode == 0:
|
||||||
|
return pm
|
||||||
|
raise SSHError("Kein unterstützter Paketmanager gefunden (apt/dnf/yum)")
|
||||||
|
|
||||||
|
async def stream_updates(self, reboot_if_required: bool = False) -> AsyncIterator[str]:
|
||||||
|
"""Run the distro update command and yield output lines live."""
|
||||||
|
import asyncssh
|
||||||
|
|
||||||
|
pm = await self.detect_package_manager()
|
||||||
|
if pm == "apt-get":
|
||||||
|
cmd = "apt-get update && DEBIAN_FRONTEND=noninteractive apt-get upgrade -y"
|
||||||
|
else:
|
||||||
|
cmd = f"{pm} update -y"
|
||||||
|
|
||||||
|
password = (self.credentials.password if self.credentials else None) or ""
|
||||||
|
full_cmd = f"echo '{password}' | sudo -S sh -c '{cmd}'"
|
||||||
|
|
||||||
|
async with asyncssh.connect(**self._connect_kwargs()) as conn:
|
||||||
|
async with conn.create_process(full_cmd) as process:
|
||||||
|
async for line in process.stdout: # type: ignore[union-attr]
|
||||||
|
yield str(line).rstrip()
|
||||||
|
await process.wait()
|
||||||
|
if process.returncode != 0:
|
||||||
|
raise SSHError(f"Update fehlgeschlagen (exit {process.returncode})")
|
||||||
|
|
||||||
|
if reboot_if_required:
|
||||||
|
async with asyncssh.connect(**self._connect_kwargs()) as conn:
|
||||||
|
check = await conn.run("test -f /var/run/reboot-required", check=False)
|
||||||
|
if check.returncode == 0:
|
||||||
|
yield "REBOOT erforderlich — wird ausgeführt..."
|
||||||
|
await conn.run(f"echo '{password}' | sudo -S reboot", check=False)
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
"""WinRM service: connect to Windows hosts, run PowerShell, stream output.
|
||||||
|
|
||||||
|
Uses python-winrm (pywinrm). All blocking calls run in a thread pool
|
||||||
|
so the async event loop is never blocked.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.core.exceptions import WinRMError
|
||||||
|
from app.core.logging import get_logger
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class WinRMCredentials:
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class WinRMService:
|
||||||
|
"""Wraps pywinrm for async usage."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
hostname: str,
|
||||||
|
port: int = 5985,
|
||||||
|
credentials: WinRMCredentials | None = None,
|
||||||
|
transport: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.hostname = hostname
|
||||||
|
self.port = port
|
||||||
|
self.credentials = credentials
|
||||||
|
self.transport = transport or settings.winrm_transport
|
||||||
|
|
||||||
|
def _build_session(self): # type: ignore[no-untyped-def]
|
||||||
|
import winrm # pywinrm
|
||||||
|
|
||||||
|
scheme = "https" if self.port == 5986 else "http"
|
||||||
|
endpoint = f"{scheme}://{self.hostname}:{self.port}/wsman"
|
||||||
|
kwargs: dict = {
|
||||||
|
"transport": self.transport,
|
||||||
|
"server_cert_validation": settings.winrm_cert_validation,
|
||||||
|
"operation_timeout_sec": settings.winrm_operation_timeout,
|
||||||
|
"read_timeout_sec": settings.winrm_read_timeout,
|
||||||
|
}
|
||||||
|
if self.credentials:
|
||||||
|
kwargs["username"] = self.credentials.username
|
||||||
|
kwargs["password"] = self.credentials.password
|
||||||
|
return winrm.Session(endpoint, **kwargs)
|
||||||
|
|
||||||
|
async def test_connection(self) -> tuple[bool, str]:
|
||||||
|
"""Run a trivial command to verify connectivity."""
|
||||||
|
|
||||||
|
def _run() -> tuple[bool, str]:
|
||||||
|
try:
|
||||||
|
session = self._build_session()
|
||||||
|
result = session.run_ps("$env:COMPUTERNAME")
|
||||||
|
if result.status_code == 0:
|
||||||
|
name = result.std_out.decode(errors="replace").strip()
|
||||||
|
return True, f"Verbunden mit {name}"
|
||||||
|
return False, result.std_err.decode(errors="replace").strip()
|
||||||
|
except Exception as exc: # noqa: BLE001 - surface any transport error
|
||||||
|
return False, str(exc)
|
||||||
|
|
||||||
|
return await asyncio.to_thread(_run)
|
||||||
|
|
||||||
|
async def run_powershell(self, script: str) -> str:
|
||||||
|
"""Run a PowerShell script and return stdout. Raises WinRMError on failure."""
|
||||||
|
|
||||||
|
def _run() -> str:
|
||||||
|
session = self._build_session()
|
||||||
|
result = session.run_ps(script)
|
||||||
|
if result.status_code != 0:
|
||||||
|
err = result.std_err.decode(errors="replace").strip()
|
||||||
|
raise WinRMError(f"PowerShell exit {result.status_code}: {err}")
|
||||||
|
return result.std_out.decode(errors="replace")
|
||||||
|
|
||||||
|
return await asyncio.to_thread(_run)
|
||||||
|
|
||||||
|
async def stream_powershell(self, script: str) -> AsyncIterator[str]:
|
||||||
|
"""Yield output lines as they arrive (scaffold: runs to completion, then yields).
|
||||||
|
|
||||||
|
TODO: switch to winrm Protocol with shell polling for true streaming.
|
||||||
|
"""
|
||||||
|
output = await self.run_powershell(script)
|
||||||
|
for line in output.splitlines():
|
||||||
|
yield line
|
||||||
|
await asyncio.sleep(0) # keep the loop responsive
|
||||||
|
|
||||||
|
async def get_pending_updates(self) -> list[str]:
|
||||||
|
"""Query Windows Update for pending updates (titles only)."""
|
||||||
|
script = """
|
||||||
|
$session = New-Object -ComObject Microsoft.Update.Session
|
||||||
|
$searcher = $session.CreateUpdateSearcher()
|
||||||
|
$result = $searcher.Search("IsInstalled=0")
|
||||||
|
$result.Updates | ForEach-Object { $_.Title }
|
||||||
|
"""
|
||||||
|
output = await self.run_powershell(script)
|
||||||
|
return [line.strip() for line in output.splitlines() if line.strip()]
|
||||||
|
|
||||||
|
async def install_updates(self) -> AsyncIterator[str]:
|
||||||
|
"""Install all pending Windows Updates, yielding progress lines."""
|
||||||
|
script = """
|
||||||
|
$session = New-Object -ComObject Microsoft.Update.Session
|
||||||
|
$searcher = $session.CreateUpdateSearcher()
|
||||||
|
$result = $searcher.Search("IsInstalled=0")
|
||||||
|
Write-Output "Gefundene Updates: $($result.Updates.Count)"
|
||||||
|
$toInstall = New-Object -ComObject Microsoft.Update.UpdateColl
|
||||||
|
$result.Updates | ForEach-Object { $toInstall.Add($_) | Out-Null }
|
||||||
|
$installer = $session.CreateUpdateInstaller()
|
||||||
|
$installer.Updates = $toInstall
|
||||||
|
$installResult = $installer.Install()
|
||||||
|
Write-Output "ResultCode: $($installResult.ResultCode)"
|
||||||
|
Write-Output "RebootRequired: $($installResult.RebootRequired)"
|
||||||
|
"""
|
||||||
|
async for line in self.stream_powershell(script):
|
||||||
|
yield line
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
"""WebSocket layer: Socket.io server, connection manager, event handlers."""
|
||||||
|
|
||||||
|
from app.websocket.handlers import sio
|
||||||
|
from app.websocket.manager import WSManager, ws_manager
|
||||||
|
|
||||||
|
__all__ = ["WSManager", "sio", "ws_manager"]
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""Socket.io server instance and event handlers.
|
||||||
|
|
||||||
|
Events (see AGENTS.md):
|
||||||
|
Server -> Client: job:start, job:log, job:progress, job:complete
|
||||||
|
Client -> Server: job:subscribe, job:unsubscribe, job:cancel
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import socketio
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.core.logging import get_logger
|
||||||
|
from app.websocket.manager import ws_manager
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
sio = socketio.AsyncServer(
|
||||||
|
async_mode="asgi",
|
||||||
|
cors_allowed_origins=settings.cors_origin_list or "*",
|
||||||
|
# Redis manager for multi-worker pub/sub; set in main.py when Redis is up
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@sio.event
|
||||||
|
async def connect(sid: str, environ: dict, auth: dict | None) -> None: # noqa: ARG001
|
||||||
|
# TODO: validate JWT from auth payload before accepting
|
||||||
|
ws_manager.register(sid)
|
||||||
|
|
||||||
|
|
||||||
|
@sio.event
|
||||||
|
async def disconnect(sid: str) -> None:
|
||||||
|
ws_manager.unregister(sid)
|
||||||
|
|
||||||
|
|
||||||
|
@sio.event
|
||||||
|
async def subscribe_job(sid: str, data: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Client subscribes to a job's live log room."""
|
||||||
|
job_id = int(data.get("job_id", 0))
|
||||||
|
room = ws_manager.subscribe(sid, job_id)
|
||||||
|
await sio.enter_room(sid, room)
|
||||||
|
logger.info("ws.subscribed", sid=sid, room=room)
|
||||||
|
return {"ok": True, "room": room}
|
||||||
|
|
||||||
|
|
||||||
|
@sio.event
|
||||||
|
async def unsubscribe_job(sid: str, data: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
job_id = int(data.get("job_id", 0))
|
||||||
|
room = ws_manager.unsubscribe(sid, job_id)
|
||||||
|
await sio.leave_room(sid, room)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@sio.event
|
||||||
|
async def cancel_job(sid: str, data: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Client requests job cancellation."""
|
||||||
|
from app.services.job_runner import job_runner
|
||||||
|
|
||||||
|
job_id = int(data.get("job_id", 0))
|
||||||
|
cancelled = await job_runner.cancel(job_id)
|
||||||
|
return {"ok": cancelled}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Emit helpers used by services / job runner
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def emit_job_start(job_id: int, server_id: int, job_type: str) -> None:
|
||||||
|
await sio.emit(
|
||||||
|
"job:start",
|
||||||
|
{"job_id": job_id, "server_id": server_id, "type": job_type},
|
||||||
|
room=ws_manager.room_for(job_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def emit_job_log(job_id: int, line: str, level: str = "info") -> None:
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
await sio.emit(
|
||||||
|
"job:log",
|
||||||
|
{"job_id": job_id, "line": line, "level": level, "timestamp": datetime.now(UTC).isoformat()},
|
||||||
|
room=ws_manager.room_for(job_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def emit_job_progress(
|
||||||
|
job_id: int, percent: int, phase: str, node: str | None = None
|
||||||
|
) -> None:
|
||||||
|
await sio.emit(
|
||||||
|
"job:progress",
|
||||||
|
{"job_id": job_id, "percent": percent, "phase": phase, "node": node},
|
||||||
|
room=ws_manager.room_for(job_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def emit_job_complete(job_id: int, status: str, duration: float | None) -> None:
|
||||||
|
await sio.emit(
|
||||||
|
"job:complete",
|
||||||
|
{"job_id": job_id, "status": status, "duration": duration},
|
||||||
|
room=ws_manager.room_for(job_id),
|
||||||
|
)
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""Connection manager for Socket.io rooms (one room per update job)."""
|
||||||
|
|
||||||
|
from app.core.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class WSManager:
|
||||||
|
"""Tracks active Socket.io sessions and job-room subscriptions."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
# sid -> set of job rooms the client subscribed to
|
||||||
|
self._subscriptions: dict[str, set[str]] = {}
|
||||||
|
|
||||||
|
def register(self, sid: str) -> None:
|
||||||
|
self._subscriptions.setdefault(sid, set())
|
||||||
|
logger.info("ws.client_connected", sid=sid)
|
||||||
|
|
||||||
|
def unregister(self, sid: str) -> None:
|
||||||
|
self._subscriptions.pop(sid, None)
|
||||||
|
logger.info("ws.client_disconnected", sid=sid)
|
||||||
|
|
||||||
|
def subscribe(self, sid: str, job_id: int) -> str:
|
||||||
|
room = self.room_for(job_id)
|
||||||
|
self._subscriptions.setdefault(sid, set()).add(room)
|
||||||
|
return room
|
||||||
|
|
||||||
|
def unsubscribe(self, sid: str, job_id: int) -> str:
|
||||||
|
room = self.room_for(job_id)
|
||||||
|
if sid in self._subscriptions:
|
||||||
|
self._subscriptions[sid].discard(room)
|
||||||
|
return room
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def room_for(job_id: int) -> str:
|
||||||
|
return f"job:{job_id}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def client_count(self) -> int:
|
||||||
|
return len(self._subscriptions)
|
||||||
|
|
||||||
|
|
||||||
|
ws_manager = WSManager()
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
[project]
|
||||||
|
name = "insight-updater-backend"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Insight Updater Backend - FastAPI application for server update orchestration"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"fastapi[standard]>=0.109.0",
|
||||||
|
"uvicorn[standard]>=0.27.0",
|
||||||
|
"sqlalchemy[asyncio]>=2.0.25",
|
||||||
|
"alembic>=1.13.0",
|
||||||
|
"aiosqlite>=0.19.0",
|
||||||
|
"asyncpg>=0.29.0",
|
||||||
|
"redis>=5.0.0",
|
||||||
|
"python-jose[cryptography]>=3.3.0",
|
||||||
|
"bcrypt>=4.1.0",
|
||||||
|
"cryptography>=42.0.0",
|
||||||
|
"pydantic[email]>=2.5.0",
|
||||||
|
"pydantic-settings>=2.1.0",
|
||||||
|
"pywinrm[kerberos]>=0.4.3",
|
||||||
|
"paramiko>=3.4.0",
|
||||||
|
"asyncssh>=2.14.0",
|
||||||
|
"python-socketio>=5.10.0",
|
||||||
|
"structlog>=24.1.0",
|
||||||
|
"python-json-logger>=2.0.7",
|
||||||
|
"python-multipart>=0.0.6",
|
||||||
|
"httpx>=0.26.0",
|
||||||
|
"tenacity>=8.2.0",
|
||||||
|
"pytz>=2024.1",
|
||||||
|
"python-dateutil>=2.8.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.0.0",
|
||||||
|
"pytest-asyncio>=0.23.0",
|
||||||
|
"pytest-cov>=4.1.0",
|
||||||
|
"httpx>=0.26.0",
|
||||||
|
"faker>=22.0.0",
|
||||||
|
"ruff>=0.2.0",
|
||||||
|
"mypy>=1.8.0",
|
||||||
|
"pre-commit>=3.6.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=68.0", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
include = ["app*"]
|
||||||
|
|
||||||
|
[tool.uv]
|
||||||
|
dev-dependencies = [
|
||||||
|
"pytest>=8.0.0",
|
||||||
|
"pytest-asyncio>=0.23.0",
|
||||||
|
"pytest-cov>=4.1.0",
|
||||||
|
"httpx>=0.26.0",
|
||||||
|
"faker>=22.0.0",
|
||||||
|
"ruff>=0.2.0",
|
||||||
|
"mypy>=1.8.0",
|
||||||
|
"pre-commit>=3.6.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
target-version = "py311"
|
||||||
|
line-length = 100
|
||||||
|
select = ["E", "W", "F", "I", "N", "UP", "B", "C4", "T20"]
|
||||||
|
ignore = ["E501", "B008"]
|
||||||
|
per-file-ignores = { "tests/*" = ["S101", "S106"] }
|
||||||
|
|
||||||
|
[tool.ruff.format]
|
||||||
|
quote-style = "double"
|
||||||
|
indent-style = "space"
|
||||||
|
skip-magic-trailing-comma = false
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
python_version = "3.11"
|
||||||
|
warn_return_any = true
|
||||||
|
warn_unused_configs = true
|
||||||
|
disallow_untyped_defs = true
|
||||||
|
disallow_incomplete_defs = true
|
||||||
|
check_untyped_defs = true
|
||||||
|
no_implicit_optional = true
|
||||||
|
strict_optional = true
|
||||||
|
enable_error_code = ["unused-ignore"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
testpaths = ["tests"]
|
||||||
|
python_files = ["test_*.py"]
|
||||||
|
python_classes = ["Test*"]
|
||||||
|
python_functions = ["test_*"]
|
||||||
|
|
||||||
|
[tool.coverage.run]
|
||||||
|
source = ["app"]
|
||||||
|
omit = ["tests/*", "*/migrations/*"]
|
||||||
|
|
||||||
|
[tool.coverage.report]
|
||||||
|
exclude_lines = [
|
||||||
|
"pragma: no cover",
|
||||||
|
"def __repr__",
|
||||||
|
"raise NotImplementedError",
|
||||||
|
"if __name__ == \"__main__\":",
|
||||||
|
]
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Core
|
||||||
|
fastapi==0.110.0
|
||||||
|
uvicorn[standard]==0.29.0
|
||||||
|
pydantic[email]==2.6.4
|
||||||
|
pydantic-settings==2.2.1
|
||||||
|
python-multipart==0.0.6
|
||||||
|
|
||||||
|
# Database
|
||||||
|
sqlalchemy[asyncio]==2.0.29
|
||||||
|
alembic==1.13.1
|
||||||
|
aiosqlite==0.19.0
|
||||||
|
asyncpg==0.29.0
|
||||||
|
|
||||||
|
# Redis & Caching
|
||||||
|
redis==5.0.1
|
||||||
|
|
||||||
|
# Security & Auth
|
||||||
|
python-jose[cryptography]==3.3.0
|
||||||
|
passlib[bcrypt]==1.7.4
|
||||||
|
cryptography==42.0.5
|
||||||
|
python-dotenv==1.0.1
|
||||||
|
|
||||||
|
# WinRM / SSH / CAU
|
||||||
|
python-winrm[kerberos]==0.5.0
|
||||||
|
paramiko==3.4.0
|
||||||
|
asyncssh==2.14.2
|
||||||
|
|
||||||
|
# WebSocket
|
||||||
|
python-socketio[asyncio]==5.10.0
|
||||||
|
|
||||||
|
# Logging & Observability
|
||||||
|
structlog==24.1.0
|
||||||
|
python-json-logger==2.0.7
|
||||||
|
|
||||||
|
# Validation & Utils
|
||||||
|
email-validator==2.1.0
|
||||||
|
python-slugify==8.0.1
|
||||||
|
pendulum==2.1.2
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
pytest==8.1.1
|
||||||
|
pytest-asyncio==0.23.6
|
||||||
|
pytest-cov==4.1.0
|
||||||
|
httpx==0.27.0
|
||||||
|
faker==25.1.0
|
||||||
|
|
||||||
|
# Type Checking
|
||||||
|
mypy==1.9.0
|
||||||
|
types-python-jose==3.3.0.20240106
|
||||||
|
types-passlib==1.7.4.20240106
|
||||||
|
types-requests==2.31.0.20240106
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# Backend - FastAPI (Production)
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
target: production
|
||||||
|
container_name: insight-updater-backend
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- APP_ENV=production
|
||||||
|
- DATABASE_URL=postgresql+asyncpg://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME}
|
||||||
|
- REDIS_URL=redis://redis:6379/0
|
||||||
|
- SECRET_KEY=${SECRET_KEY}
|
||||||
|
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
|
||||||
|
- JWT_PRIVATE_KEY_PATH=/app/keys/private.pem
|
||||||
|
- JWT_PUBLIC_KEY_PATH=/app/keys/public.pem
|
||||||
|
- WINRM_TRANSPORT=${WINRM_TRANSPORT:-ntlm}
|
||||||
|
- WINRM_CERT_VALIDATION=${WINRM_CERT_VALIDATION:-validate}
|
||||||
|
- LDAP_ENABLED=${LDAP_ENABLED:-false}
|
||||||
|
- LDAP_URI=${LDAP_URI}
|
||||||
|
- LDAP_BIND_DN=${LDAP_BIND_DN}
|
||||||
|
- LDAP_BIND_PASSWORD=${LDAP_BIND_PASSWORD}
|
||||||
|
- LDAP_USER_SEARCH_BASE=${LDAP_USER_SEARCH_BASE}
|
||||||
|
- LDAP_USER_FILTER=${LDAP_USER_FILTER}
|
||||||
|
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||||
|
- LOG_FORMAT=json
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
- ./keys:/app/keys:ro
|
||||||
|
- ./certs:/app/certs:ro
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
- traefik-public
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: '1.0'
|
||||||
|
memory: 1G
|
||||||
|
reservations:
|
||||||
|
cpus: '0.25'
|
||||||
|
memory: 256M
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 20s
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.docker.network=traefik-public"
|
||||||
|
- "traefik.http.routers.updater-api.rule=Host(`${DOMAIN}`) && PathPrefix(`/api`)"
|
||||||
|
- "traefik.http.routers.updater-api.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.updater-api.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.services.updater-api.loadbalancer.server.port=8000"
|
||||||
|
- "traefik.http.routers.updater-ws.rule=Host(`${DOMAIN}`) && PathPrefix(`/ws`)"
|
||||||
|
- "traefik.http.routers.updater-ws.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.updater-ws.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.services.updater-ws.loadbalancer.server.port=8000"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# Frontend - Nginx (Production)
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
target: production
|
||||||
|
container_name: insight-updater-frontend
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
- traefik-public
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.docker.network=traefik-public"
|
||||||
|
- "traefik.http.routers.updater-web.rule=Host(`${DOMAIN}`)"
|
||||||
|
- "traefik.http.routers.updater-web.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.updater-web.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.services.updater-web.loadbalancer.server.port=80"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# Redis - Pub/Sub, Caching, Rate Limiting
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: insight-updater-redis
|
||||||
|
restart: unless-stopped
|
||||||
|
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
|
||||||
|
volumes:
|
||||||
|
- redis-data:/data
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# PostgreSQL - Production Database
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: insight-updater-db
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- POSTGRES_DB=${DB_NAME}
|
||||||
|
- POSTGRES_USER=${DB_USER}
|
||||||
|
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- pg-data:/var/lib/postgresql/data
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 3
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: '0.5'
|
||||||
|
memory: 512M
|
||||||
|
|
||||||
|
networks:
|
||||||
|
internal:
|
||||||
|
driver: bridge
|
||||||
|
traefik-public:
|
||||||
|
external: true
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
redis-data:
|
||||||
|
pg-data:
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# BACKEND - FastAPI
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: insight-updater-backend
|
||||||
|
environment:
|
||||||
|
- APP_ENV=development
|
||||||
|
- DATABASE_URL=sqlite+aiosqlite:///./data/app.db
|
||||||
|
- REDIS_URL=redis://redis:6379/0
|
||||||
|
- SECRET_KEY=${SECRET_KEY:-dev-secret-change-me-32-chars-min}
|
||||||
|
- ENCRYPTION_KEY=${ENCRYPTION_KEY:-dev-encryption-key-32-chars-base64}
|
||||||
|
- JWT_ALGORITHM=RS256
|
||||||
|
- JWT_PRIVATE_KEY_PATH=/app/keys/private.pem
|
||||||
|
- JWT_PUBLIC_KEY_PATH=/app/keys/public.pem
|
||||||
|
- WINRM_TRANSPORT=ntlm
|
||||||
|
- WINRM_CERT_VALIDATION=ignore
|
||||||
|
- LDAP_ENABLED=false
|
||||||
|
- LOG_LEVEL=DEBUG
|
||||||
|
volumes:
|
||||||
|
- ./backend:/app
|
||||||
|
- ./data:/app/data
|
||||||
|
- ./backend/keys:/app/keys:ro
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
depends_on:
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 10s
|
||||||
|
networks:
|
||||||
|
- insight-updater-network
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# FRONTEND - Vue 3 + Vite (dev) / Nginx (prod)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
target: development
|
||||||
|
container_name: insight-updater-frontend
|
||||||
|
environment:
|
||||||
|
- VITE_API_URL=http://localhost:8000
|
||||||
|
- VITE_WS_URL=ws://localhost:8000
|
||||||
|
volumes:
|
||||||
|
- ./frontend:/app
|
||||||
|
- /app/node_modules
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
networks:
|
||||||
|
- insight-updater-network
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# REDIS - for WebSocket pub/sub, caching, rate limiting
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: insight-updater-redis
|
||||||
|
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
|
||||||
|
volumes:
|
||||||
|
- redis-data:/data
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- insight-updater-network
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POSTGRESQL (optional, for production)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# postgres:
|
||||||
|
# image: postgres:16-alpine
|
||||||
|
# container_name: insight-updater-postgres
|
||||||
|
# environment:
|
||||||
|
# - POSTGRES_DB=updater
|
||||||
|
# - POSTGRES_USER=updater
|
||||||
|
# - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||||
|
# volumes:
|
||||||
|
# - postgres-data:/var/lib/postgresql/data
|
||||||
|
# ports:
|
||||||
|
# - "5432:5432"
|
||||||
|
# healthcheck:
|
||||||
|
# test: ["CMD-SHELL", "pg_isready -U updater -d updater"]
|
||||||
|
# interval: 5s
|
||||||
|
# timeout: 5s
|
||||||
|
# retries: 5
|
||||||
|
# networks:
|
||||||
|
# - insight-updater-network
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
redis-data:
|
||||||
|
# postgres-data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
insight-updater-network:
|
||||||
|
driver: bridge
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Development stage
|
||||||
|
FROM node:20-alpine AS development
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Copy source
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["npm", "run", "dev"]
|
||||||
|
|
||||||
|
|
||||||
|
# Production build stage
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
|
||||||
|
# Production stage
|
||||||
|
FROM nginx:alpine AS production
|
||||||
|
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Insight Updater</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
|
||||||
|
# Gzip compression
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml application/json application/rss+xml image/svg+xml;
|
||||||
|
|
||||||
|
# Cache static assets
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# API proxy (optional, for same-origin calls)
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend:8000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# WebSocket proxy
|
||||||
|
location /ws/ {
|
||||||
|
proxy_pass http://backend:8000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 86400;
|
||||||
|
}
|
||||||
|
|
||||||
|
# SPA fallback - serve index.html for all non-file routes
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Health check endpoint
|
||||||
|
location /health {
|
||||||
|
access_log off;
|
||||||
|
return 200 "healthy\n";
|
||||||
|
add_header Content-Type text/plain;
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+6471
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"name": "insight-updater-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"test": "vitest",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"lint": "eslint . --ext .vue,.ts,.js --fix",
|
||||||
|
"format": "prettier --write ."
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.4.0",
|
||||||
|
"vue-router": "^4.3.0",
|
||||||
|
"pinia": "^2.1.0",
|
||||||
|
"@vueuse/core": "^10.9.0",
|
||||||
|
"axios": "^1.6.0",
|
||||||
|
"socket.io-client": "^4.7.0",
|
||||||
|
"date-fns": "^3.6.0",
|
||||||
|
"zod": "^3.22.0",
|
||||||
|
"@tanstack/vue-query": "^5.0.0",
|
||||||
|
"lucide-vue-next": "^0.378.0",
|
||||||
|
"clsx": "^2.1.0",
|
||||||
|
"tailwind-merge": "^2.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.0.0",
|
||||||
|
"vite": "^5.2.0",
|
||||||
|
"vue-tsc": "^2.0.0",
|
||||||
|
"typescript": "^5.4.0",
|
||||||
|
"tailwindcss": "^3.4.0",
|
||||||
|
"postcss": "^8.4.0",
|
||||||
|
"autoprefixer": "^10.4.0",
|
||||||
|
"eslint": "^8.57.0",
|
||||||
|
"@vue/eslint-config-typescript": "^13.0.0",
|
||||||
|
"prettier": "^3.2.0",
|
||||||
|
"@tailwindcss/forms": "^0.5.0",
|
||||||
|
"vitest": "^1.5.0",
|
||||||
|
"@vue/test-utils": "^2.4.0",
|
||||||
|
"jsdom": "^24.0.0",
|
||||||
|
"@playwright/test": "^1.43.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import AppLayout from '@/components/AppLayout.vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const isLogin = computed(() => route.name === 'login')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<router-view v-if="isLogin" />
|
||||||
|
<AppLayout v-else>
|
||||||
|
<router-view />
|
||||||
|
</AppLayout>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import router from '@/router'
|
||||||
|
|
||||||
|
const baseURL = import.meta.env.VITE_API_URL || ''
|
||||||
|
|
||||||
|
export const apiClient = axios.create({
|
||||||
|
baseURL,
|
||||||
|
timeout: 30000,
|
||||||
|
})
|
||||||
|
|
||||||
|
apiClient.interceptors.request.use((config) => {
|
||||||
|
const auth = useAuthStore()
|
||||||
|
if (auth.token) {
|
||||||
|
config.headers.Authorization = `Bearer ${auth.token}`
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
|
apiClient.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error) => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
const auth = useAuthStore()
|
||||||
|
auth.logout()
|
||||||
|
router.push({ name: 'login' })
|
||||||
|
}
|
||||||
|
return Promise.reject(error)
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { io, type Socket } from 'socket.io-client'
|
||||||
|
|
||||||
|
let socket: Socket | null = null
|
||||||
|
|
||||||
|
export function getSocket(): Socket {
|
||||||
|
if (!socket) {
|
||||||
|
const url = import.meta.env.VITE_WS_URL || window.location.origin
|
||||||
|
socket = io(url, {
|
||||||
|
path: '/socket.io',
|
||||||
|
transports: ['websocket', 'polling'],
|
||||||
|
autoConnect: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return socket
|
||||||
|
}
|
||||||
|
|
||||||
|
export function disconnectSocket(): void {
|
||||||
|
if (socket) {
|
||||||
|
socket.disconnect()
|
||||||
|
socket = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ name: 'dashboard', label: 'Dashboard' },
|
||||||
|
{ name: 'servers', label: 'Server' },
|
||||||
|
{ name: 'updates', label: 'Updates' },
|
||||||
|
{ name: 'audit', label: 'Audit' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function logout(): void {
|
||||||
|
auth.logout()
|
||||||
|
router.push({ name: 'login' })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="min-h-screen">
|
||||||
|
<nav class="bg-slate-800 text-white shadow">
|
||||||
|
<div class="mx-auto max-w-7xl px-4">
|
||||||
|
<div class="flex h-14 items-center justify-between">
|
||||||
|
<div class="flex items-center gap-6">
|
||||||
|
<span class="text-lg font-bold">Insight Updater</span>
|
||||||
|
<router-link
|
||||||
|
v-for="item in navItems"
|
||||||
|
:key="item.name"
|
||||||
|
:to="{ name: item.name }"
|
||||||
|
class="rounded px-3 py-1.5 text-sm hover:bg-slate-700"
|
||||||
|
active-class="bg-slate-900 font-semibold"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="rounded bg-slate-700 px-3 py-1.5 text-sm hover:bg-slate-600"
|
||||||
|
@click="logout"
|
||||||
|
>
|
||||||
|
Abmelden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<main class="mx-auto max-w-7xl px-4 py-6">
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
Vendored
+7
@@ -0,0 +1,7 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module '*.vue' {
|
||||||
|
import type { DefineComponent } from 'vue'
|
||||||
|
const component: DefineComponent<{}, {}, any>
|
||||||
|
export default component
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
import './style.css'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
app.use(createPinia())
|
||||||
|
app.use(router)
|
||||||
|
app.mount('#app')
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
name: 'login',
|
||||||
|
component: () => import('@/views/LoginView.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
name: 'dashboard',
|
||||||
|
component: () => import('@/views/DashboardView.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/servers',
|
||||||
|
name: 'servers',
|
||||||
|
component: () => import('@/views/ServersView.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/updates',
|
||||||
|
name: 'updates',
|
||||||
|
component: () => import('@/views/UpdatesView.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/audit',
|
||||||
|
name: 'audit',
|
||||||
|
component: () => import('@/views/AuditView.vue'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
router.beforeEach((to) => {
|
||||||
|
const auth = useAuthStore()
|
||||||
|
if (to.name !== 'login' && !auth.isAuthenticated) {
|
||||||
|
return { name: 'login' }
|
||||||
|
}
|
||||||
|
if (to.name === 'login' && auth.isAuthenticated) {
|
||||||
|
return { name: 'dashboard' }
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { apiClient } from '@/api/client'
|
||||||
|
import { disconnectSocket } from '@/api/socket'
|
||||||
|
|
||||||
|
const TOKEN_KEY = 'iu_token'
|
||||||
|
|
||||||
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
|
const token = ref<string | null>(localStorage.getItem(TOKEN_KEY))
|
||||||
|
const username = ref<string | null>(null)
|
||||||
|
|
||||||
|
const isAuthenticated = computed(() => token.value !== null)
|
||||||
|
|
||||||
|
async function login(user: string, password: string): Promise<void> {
|
||||||
|
const { data } = await apiClient.post('/api/auth/login', {
|
||||||
|
username: user,
|
||||||
|
password,
|
||||||
|
})
|
||||||
|
token.value = data.access_token
|
||||||
|
username.value = user
|
||||||
|
localStorage.setItem(TOKEN_KEY, data.access_token)
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout(): void {
|
||||||
|
token.value = null
|
||||||
|
username.value = null
|
||||||
|
localStorage.removeItem(TOKEN_KEY)
|
||||||
|
disconnectSocket()
|
||||||
|
}
|
||||||
|
|
||||||
|
return { token, username, isAuthenticated, login, logout }
|
||||||
|
})
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { apiClient } from '@/api/client'
|
||||||
|
import type { Server, HealthResult } from '@/types'
|
||||||
|
|
||||||
|
export const useServersStore = defineStore('servers', () => {
|
||||||
|
const servers = ref<Server[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const healthResults = ref<Record<number, HealthResult>>({})
|
||||||
|
|
||||||
|
async function fetchServers(): Promise<void> {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const { data } = await apiClient.get<Server[]>('/api/servers')
|
||||||
|
servers.value = data
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createServer(payload: Partial<Server>): Promise<Server> {
|
||||||
|
const { data } = await apiClient.post<Server>('/api/servers', payload)
|
||||||
|
servers.value.push(data)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteServer(id: number): Promise<void> {
|
||||||
|
await apiClient.delete(`/api/servers/${id}`)
|
||||||
|
servers.value = servers.value.filter((s) => s.id !== id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkHealth(id: number): Promise<HealthResult> {
|
||||||
|
const { data } = await apiClient.get<HealthResult>(`/api/servers/${id}/health`)
|
||||||
|
healthResults.value[id] = data
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
return { servers, loading, healthResults, fetchServers, createServer, deleteServer, checkHealth }
|
||||||
|
})
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, onUnmounted } from 'vue'
|
||||||
|
import { apiClient } from '@/api/client'
|
||||||
|
import { getSocket } from '@/api/socket'
|
||||||
|
import type { UpdateJob, UpdateLogLine, JobType } from '@/types'
|
||||||
|
|
||||||
|
interface WsLogPayload {
|
||||||
|
job_id: number
|
||||||
|
line: string
|
||||||
|
level: string
|
||||||
|
timestamp: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WsCompletePayload {
|
||||||
|
job_id: number
|
||||||
|
status: string
|
||||||
|
duration: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useUpdatesStore = defineStore('updates', () => {
|
||||||
|
const jobs = ref<UpdateJob[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const liveLogs = ref<Record<number, UpdateLogLine[]>>({})
|
||||||
|
|
||||||
|
async function fetchJobs(): Promise<void> {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const { data } = await apiClient.get<UpdateJob[]>('/api/updates')
|
||||||
|
jobs.value = data
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function triggerUpdate(serverId: number, type: JobType): Promise<UpdateJob> {
|
||||||
|
const { data } = await apiClient.post<UpdateJob>('/api/updates/trigger', {
|
||||||
|
server_id: serverId,
|
||||||
|
type,
|
||||||
|
})
|
||||||
|
jobs.value.unshift(data)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelJob(jobId: number): Promise<void> {
|
||||||
|
await apiClient.post(`/api/updates/${jobId}/cancel`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchLogs(jobId: number, afterId = 0): Promise<void> {
|
||||||
|
const { data } = await apiClient.get<UpdateLogLine[]>(`/api/updates/${jobId}/logs`, {
|
||||||
|
params: { after_id: afterId },
|
||||||
|
})
|
||||||
|
const existing = liveLogs.value[jobId] || []
|
||||||
|
liveLogs.value[jobId] = afterId === 0 ? data : [...existing, ...data]
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribeJob(jobId: number): void {
|
||||||
|
const socket = getSocket()
|
||||||
|
socket.emit('subscribe_job', { job_id: jobId })
|
||||||
|
|
||||||
|
socket.off('job:log')
|
||||||
|
socket.on('job:log', (payload: WsLogPayload) => {
|
||||||
|
const list = liveLogs.value[payload.job_id] || []
|
||||||
|
liveLogs.value[payload.job_id] = [
|
||||||
|
...list,
|
||||||
|
{ id: list.length + 1, job_id: payload.job_id, timestamp: payload.timestamp, level: payload.level, line: payload.line },
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.off('job:complete')
|
||||||
|
socket.on('job:complete', (payload: WsCompletePayload) => {
|
||||||
|
const job = jobs.value.find((j) => j.id === payload.job_id)
|
||||||
|
if (job) {
|
||||||
|
job.status = payload.status as UpdateJob['status']
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function unsubscribeJob(jobId: number): void {
|
||||||
|
const socket = getSocket()
|
||||||
|
socket.emit('unsubscribe_job', { job_id: jobId })
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
const socket = getSocket()
|
||||||
|
socket.off('job:log')
|
||||||
|
socket.off('job:complete')
|
||||||
|
})
|
||||||
|
|
||||||
|
return { jobs, loading, liveLogs, fetchJobs, triggerUpdate, cancelJob, fetchLogs, subscribeJob, unsubscribeJob }
|
||||||
|
})
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-slate-100 text-slate-900 antialiased;
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
export type ServerType = 'windows' | 'linux' | 'cau_cluster'
|
||||||
|
export type JobStatus = 'pending' | 'running' | 'success' | 'failed' | 'cancelled'
|
||||||
|
export type JobType = 'windows_update' | 'linux_update' | 'cau_run' | 'health_check'
|
||||||
|
|
||||||
|
export interface Server {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
hostname: string
|
||||||
|
port: number
|
||||||
|
type: ServerType
|
||||||
|
description: string | null
|
||||||
|
tags: string | null
|
||||||
|
credential_id: number | null
|
||||||
|
last_health_at: string | null
|
||||||
|
last_health_ok: boolean | null
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateJob {
|
||||||
|
id: number
|
||||||
|
server_id: number
|
||||||
|
type: JobType
|
||||||
|
status: JobStatus
|
||||||
|
progress_percent: number
|
||||||
|
current_phase: string | null
|
||||||
|
started_by: string
|
||||||
|
started_at: string | null
|
||||||
|
finished_at: string | null
|
||||||
|
error: string | null
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateLogLine {
|
||||||
|
id: number
|
||||||
|
job_id: number
|
||||||
|
timestamp: string
|
||||||
|
level: string
|
||||||
|
line: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditEntry {
|
||||||
|
id: number
|
||||||
|
timestamp: string
|
||||||
|
username: string
|
||||||
|
action: string
|
||||||
|
target: string | null
|
||||||
|
result: string
|
||||||
|
details: string | null
|
||||||
|
ip_address: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HealthResult {
|
||||||
|
server_id: number
|
||||||
|
ok: boolean
|
||||||
|
latency_ms: number | null
|
||||||
|
message: string
|
||||||
|
checked_at: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { apiClient } from '@/api/client'
|
||||||
|
import type { AuditEntry } from '@/types'
|
||||||
|
|
||||||
|
interface AuditPage {
|
||||||
|
items: AuditEntry[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
page_size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = ref<AuditEntry[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = 50
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
async function load(): Promise<void> {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const { data } = await apiClient.get<AuditPage>('/api/audit', {
|
||||||
|
params: { page: page.value, page_size: pageSize },
|
||||||
|
})
|
||||||
|
entries.value = data.items
|
||||||
|
total.value = data.total
|
||||||
|
} catch {
|
||||||
|
error.value = 'Audit-Log konnte nicht geladen werden (Admin-Rechte erforderlich).'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h1 class="mb-6 text-2xl font-bold">Audit-Log</h1>
|
||||||
|
|
||||||
|
<p v-if="error" class="mb-4 rounded bg-red-50 p-3 text-sm text-red-700">{{ error }}</p>
|
||||||
|
|
||||||
|
<div class="overflow-hidden rounded-lg bg-white shadow">
|
||||||
|
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||||
|
<thead class="bg-slate-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-2 text-left font-medium">Zeitpunkt</th>
|
||||||
|
<th class="px-4 py-2 text-left font-medium">Benutzer</th>
|
||||||
|
<th class="px-4 py-2 text-left font-medium">Aktion</th>
|
||||||
|
<th class="px-4 py-2 text-left font-medium">Ziel</th>
|
||||||
|
<th class="px-4 py-2 text-left font-medium">Ergebnis</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-100">
|
||||||
|
<tr v-for="entry in entries" :key="entry.id">
|
||||||
|
<td class="px-4 py-2">{{ new Date(entry.timestamp).toLocaleString('de-DE') }}</td>
|
||||||
|
<td class="px-4 py-2">{{ entry.username }}</td>
|
||||||
|
<td class="px-4 py-2">{{ entry.action }}</td>
|
||||||
|
<td class="px-4 py-2">{{ entry.target || '—' }}</td>
|
||||||
|
<td class="px-4 py-2">
|
||||||
|
<span :class="entry.result === 'success' ? 'text-green-600' : 'text-red-600'">
|
||||||
|
{{ entry.result }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="entries.length === 0 && !loading">
|
||||||
|
<td colspan="5" class="px-4 py-6 text-center text-slate-500">Keine Einträge.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 flex items-center justify-between text-sm">
|
||||||
|
<span>{{ total }} Einträge gesamt</span>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
:disabled="page <= 1"
|
||||||
|
class="rounded bg-slate-200 px-3 py-1 disabled:opacity-50"
|
||||||
|
@click="page--; load()"
|
||||||
|
>
|
||||||
|
Zurück
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
:disabled="page * pageSize >= total"
|
||||||
|
class="rounded bg-slate-200 px-3 py-1 disabled:opacity-50"
|
||||||
|
@click="page++; load()"
|
||||||
|
>
|
||||||
|
Weiter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { useServersStore } from '@/stores/servers'
|
||||||
|
import { useUpdatesStore } from '@/stores/updates'
|
||||||
|
import { apiClient } from '@/api/client'
|
||||||
|
|
||||||
|
const serversStore = useServersStore()
|
||||||
|
const updatesStore = useUpdatesStore()
|
||||||
|
|
||||||
|
const stats = ref<{ total: number; running: number; failed: number }>({
|
||||||
|
total: 0,
|
||||||
|
running: 0,
|
||||||
|
failed: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const healthyCount = computed(
|
||||||
|
() => serversStore.servers.filter((s) => s.last_health_ok === true).length,
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await Promise.all([
|
||||||
|
serversStore.fetchServers(),
|
||||||
|
updatesStore.fetchJobs(),
|
||||||
|
apiClient.get('/api/updates/stats/summary').then(({ data }) => (stats.value = data)),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h1 class="mb-6 text-2xl font-bold">Dashboard</h1>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<div class="rounded-lg bg-white p-5 shadow">
|
||||||
|
<p class="text-sm text-slate-500">Server gesamt</p>
|
||||||
|
<p class="mt-1 text-3xl font-bold">{{ serversStore.servers.length }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-white p-5 shadow">
|
||||||
|
<p class="text-sm text-slate-500">Erreichbar</p>
|
||||||
|
<p class="mt-1 text-3xl font-bold text-green-600">{{ healthyCount }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-white p-5 shadow">
|
||||||
|
<p class="text-sm text-slate-500">Jobs laufend</p>
|
||||||
|
<p class="mt-1 text-3xl font-bold text-blue-600">{{ stats.running }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-white p-5 shadow">
|
||||||
|
<p class="text-sm text-slate-500">Jobs fehlgeschlagen</p>
|
||||||
|
<p class="mt-1 text-3xl font-bold text-red-600">{{ stats.failed }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="mb-3 mt-8 text-lg font-semibold">Letzte Jobs</h2>
|
||||||
|
<div class="overflow-hidden rounded-lg bg-white shadow">
|
||||||
|
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||||
|
<thead class="bg-slate-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-2 text-left font-medium">ID</th>
|
||||||
|
<th class="px-4 py-2 text-left font-medium">Server</th>
|
||||||
|
<th class="px-4 py-2 text-left font-medium">Typ</th>
|
||||||
|
<th class="px-4 py-2 text-left font-medium">Status</th>
|
||||||
|
<th class="px-4 py-2 text-left font-medium">Gestartet von</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-100">
|
||||||
|
<tr v-for="job in updatesStore.jobs.slice(0, 10)" :key="job.id">
|
||||||
|
<td class="px-4 py-2">{{ job.id }}</td>
|
||||||
|
<td class="px-4 py-2">{{ job.server_id }}</td>
|
||||||
|
<td class="px-4 py-2">{{ job.type }}</td>
|
||||||
|
<td class="px-4 py-2">
|
||||||
|
<span
|
||||||
|
class="rounded-full px-2 py-0.5 text-xs font-semibold"
|
||||||
|
:class="{
|
||||||
|
'bg-green-100 text-green-800': job.status === 'success',
|
||||||
|
'bg-red-100 text-red-800': job.status === 'failed',
|
||||||
|
'bg-blue-100 text-blue-800': job.status === 'running',
|
||||||
|
'bg-slate-100 text-slate-800': job.status === 'pending' || job.status === 'cancelled',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ job.status }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2">{{ job.started_by }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="updatesStore.jobs.length === 0">
|
||||||
|
<td colspan="5" class="px-4 py-6 text-center text-slate-500">
|
||||||
|
Noch keine Jobs vorhanden.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const username = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
async function submit(): Promise<void> {
|
||||||
|
error.value = null
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await auth.login(username.value, password.value)
|
||||||
|
router.push({ name: 'dashboard' })
|
||||||
|
} catch {
|
||||||
|
error.value = 'Anmeldung fehlgeschlagen — Benutzername oder Passwort falsch.'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex min-h-screen items-center justify-center bg-slate-800">
|
||||||
|
<div class="w-full max-w-sm rounded-lg bg-white p-8 shadow-xl">
|
||||||
|
<h1 class="mb-6 text-center text-2xl font-bold">Insight Updater</h1>
|
||||||
|
<form class="space-y-4" @submit.prevent="submit">
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium">Benutzername</label>
|
||||||
|
<input
|
||||||
|
v-model="username"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
class="w-full rounded border-slate-300"
|
||||||
|
autocomplete="username"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium">Passwort</label>
|
||||||
|
<input
|
||||||
|
v-model="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
class="w-full rounded border-slate-300"
|
||||||
|
autocomplete="current-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p v-if="error" class="text-sm text-red-600">{{ error }}</p>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="loading"
|
||||||
|
class="w-full rounded bg-slate-800 py-2 font-semibold text-white hover:bg-slate-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{{ loading ? 'Anmelden…' : 'Anmelden' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
|
import { useServersStore } from '@/stores/servers'
|
||||||
|
import type { ServerType } from '@/types'
|
||||||
|
|
||||||
|
const store = useServersStore()
|
||||||
|
|
||||||
|
const showForm = ref(false)
|
||||||
|
const form = reactive({
|
||||||
|
name: '',
|
||||||
|
hostname: '',
|
||||||
|
port: 5985,
|
||||||
|
type: 'windows' as ServerType,
|
||||||
|
description: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const typeLabels: Record<ServerType, string> = {
|
||||||
|
windows: 'Windows (WinRM)',
|
||||||
|
linux: 'Linux (SSH)',
|
||||||
|
cau_cluster: 'CAU Cluster',
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => store.fetchServers())
|
||||||
|
|
||||||
|
async function submit(): Promise<void> {
|
||||||
|
await store.createServer({ ...form })
|
||||||
|
showForm.value = false
|
||||||
|
form.name = ''
|
||||||
|
form.hostname = ''
|
||||||
|
form.port = 5985
|
||||||
|
form.type = 'windows'
|
||||||
|
form.description = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id: number): Promise<void> {
|
||||||
|
if (confirm('Server wirklich löschen?')) {
|
||||||
|
await store.deleteServer(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="mb-6 flex items-center justify-between">
|
||||||
|
<h1 class="text-2xl font-bold">Server</h1>
|
||||||
|
<button
|
||||||
|
class="rounded bg-slate-800 px-4 py-2 text-sm font-semibold text-white hover:bg-slate-700"
|
||||||
|
@click="showForm = !showForm"
|
||||||
|
>
|
||||||
|
{{ showForm ? 'Abbrechen' : 'Server hinzufügen' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showForm" class="mb-6 rounded-lg bg-white p-5 shadow">
|
||||||
|
<form class="grid grid-cols-1 gap-4 sm:grid-cols-2" @submit.prevent="submit">
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium">Name</label>
|
||||||
|
<input v-model="form.name" required class="w-full rounded border-slate-300" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium">Hostname / FQDN</label>
|
||||||
|
<input v-model="form.hostname" required class="w-full rounded border-slate-300" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium">Port</label>
|
||||||
|
<input v-model.number="form.port" type="number" required class="w-full rounded border-slate-300" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium">Typ</label>
|
||||||
|
<select v-model="form.type" class="w-full rounded border-slate-300">
|
||||||
|
<option v-for="(label, value) in typeLabels" :key="value" :value="value">
|
||||||
|
{{ label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="sm:col-span-2">
|
||||||
|
<label class="mb-1 block text-sm font-medium">Beschreibung</label>
|
||||||
|
<input v-model="form.description" class="w-full rounded border-slate-300" />
|
||||||
|
</div>
|
||||||
|
<div class="sm:col-span-2">
|
||||||
|
<button type="submit" class="rounded bg-green-700 px-4 py-2 text-sm font-semibold text-white hover:bg-green-600">
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overflow-hidden rounded-lg bg-white shadow">
|
||||||
|
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||||
|
<thead class="bg-slate-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-2 text-left font-medium">Name</th>
|
||||||
|
<th class="px-4 py-2 text-left font-medium">Hostname</th>
|
||||||
|
<th class="px-4 py-2 text-left font-medium">Typ</th>
|
||||||
|
<th class="px-4 py-2 text-left font-medium">Health</th>
|
||||||
|
<th class="px-4 py-2 text-right font-medium">Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-100">
|
||||||
|
<tr v-for="server in store.servers" :key="server.id">
|
||||||
|
<td class="px-4 py-2 font-medium">{{ server.name }}</td>
|
||||||
|
<td class="px-4 py-2">{{ server.hostname }}:{{ server.port }}</td>
|
||||||
|
<td class="px-4 py-2">{{ typeLabels[server.type] }}</td>
|
||||||
|
<td class="px-4 py-2">
|
||||||
|
<span v-if="server.last_health_ok === true" class="text-green-600">OK</span>
|
||||||
|
<span v-else-if="server.last_health_ok === false" class="text-red-600">Fehler</span>
|
||||||
|
<span v-else class="text-slate-400">—</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2 text-right">
|
||||||
|
<button
|
||||||
|
class="mr-2 rounded bg-blue-600 px-2 py-1 text-xs text-white hover:bg-blue-500"
|
||||||
|
@click="store.checkHealth(server.id)"
|
||||||
|
>
|
||||||
|
Health-Check
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="rounded bg-red-600 px-2 py-1 text-xs text-white hover:bg-red-500"
|
||||||
|
@click="remove(server.id)"
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="store.servers.length === 0">
|
||||||
|
<td colspan="5" class="px-4 py-6 text-center text-slate-500">
|
||||||
|
Noch keine Server im Inventar.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { useServersStore } from '@/stores/servers'
|
||||||
|
import { useUpdatesStore } from '@/stores/updates'
|
||||||
|
import type { JobType } from '@/types'
|
||||||
|
|
||||||
|
const serversStore = useServersStore()
|
||||||
|
const updatesStore = useUpdatesStore()
|
||||||
|
|
||||||
|
const selectedServerId = ref<number | null>(null)
|
||||||
|
const selectedJobId = ref<number | null>(null)
|
||||||
|
|
||||||
|
const jobTypeForServer = computed<JobType>(() => {
|
||||||
|
const server = serversStore.servers.find((s) => s.id === selectedServerId.value)
|
||||||
|
if (!server) return 'windows_update'
|
||||||
|
if (server.type === 'linux') return 'linux_update'
|
||||||
|
if (server.type === 'cau_cluster') return 'cau_run'
|
||||||
|
return 'windows_update'
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedLogs = computed(() =>
|
||||||
|
selectedJobId.value ? updatesStore.liveLogs[selectedJobId.value] || [] : [],
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await Promise.all([serversStore.fetchServers(), updatesStore.fetchJobs()])
|
||||||
|
})
|
||||||
|
|
||||||
|
async function trigger(): Promise<void> {
|
||||||
|
if (!selectedServerId.value) return
|
||||||
|
const job = await updatesStore.triggerUpdate(selectedServerId.value, jobTypeForServer.value)
|
||||||
|
watchJob(job.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function watchJob(jobId: number): Promise<void> {
|
||||||
|
selectedJobId.value = jobId
|
||||||
|
await updatesStore.fetchLogs(jobId)
|
||||||
|
updatesStore.subscribeJob(jobId)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h1 class="mb-6 text-2xl font-bold">Updates</h1>
|
||||||
|
|
||||||
|
<div class="mb-6 flex items-end gap-3 rounded-lg bg-white p-5 shadow">
|
||||||
|
<div class="flex-1">
|
||||||
|
<label class="mb-1 block text-sm font-medium">Server auswählen</label>
|
||||||
|
<select v-model="selectedServerId" class="w-full rounded border-slate-300">
|
||||||
|
<option :value="null" disabled>— bitte wählen —</option>
|
||||||
|
<option v-for="server in serversStore.servers" :key="server.id" :value="server.id">
|
||||||
|
{{ server.name }} ({{ server.hostname }})
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
:disabled="!selectedServerId"
|
||||||
|
class="rounded bg-green-700 px-4 py-2 text-sm font-semibold text-white hover:bg-green-600 disabled:opacity-50"
|
||||||
|
@click="trigger"
|
||||||
|
>
|
||||||
|
Update starten
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||||
|
<div class="overflow-hidden rounded-lg bg-white shadow">
|
||||||
|
<h2 class="border-b bg-slate-50 px-4 py-2 font-semibold">Jobs</h2>
|
||||||
|
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||||
|
<tbody class="divide-y divide-slate-100">
|
||||||
|
<tr
|
||||||
|
v-for="job in updatesStore.jobs"
|
||||||
|
:key="job.id"
|
||||||
|
class="cursor-pointer hover:bg-slate-50"
|
||||||
|
:class="{ 'bg-blue-50': job.id === selectedJobId }"
|
||||||
|
@click="watchJob(job.id)"
|
||||||
|
>
|
||||||
|
<td class="px-4 py-2">#{{ job.id }}</td>
|
||||||
|
<td class="px-4 py-2">{{ job.type }}</td>
|
||||||
|
<td class="px-4 py-2">{{ job.status }}</td>
|
||||||
|
<td class="px-4 py-2 text-right">
|
||||||
|
<button
|
||||||
|
v-if="job.status === 'running' || job.status === 'pending'"
|
||||||
|
class="rounded bg-red-600 px-2 py-1 text-xs text-white hover:bg-red-500"
|
||||||
|
@click.stop="updatesStore.cancelJob(job.id)"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="updatesStore.jobs.length === 0">
|
||||||
|
<td colspan="4" class="px-4 py-6 text-center text-slate-500">Keine Jobs.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg bg-slate-900 p-4 font-mono text-xs text-green-300 shadow">
|
||||||
|
<h2 class="mb-2 font-sans text-sm font-semibold text-slate-300">
|
||||||
|
Live-Log {{ selectedJobId ? `(Job #${selectedJobId})` : '' }}
|
||||||
|
</h2>
|
||||||
|
<div class="max-h-96 overflow-y-auto whitespace-pre-wrap">
|
||||||
|
<p v-if="selectedLogs.length === 0" class="text-slate-500">
|
||||||
|
Kein Job ausgewählt — klicke links einen Job an.
|
||||||
|
</p>
|
||||||
|
<p v-for="log in selectedLogs" :key="log.id">{{ log.line }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ['./index.html', './src/**/*.{vue,ts}'],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [require('@tailwindcss/forms')],
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
},
|
||||||
|
"types": ["vite/client"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 3000,
|
||||||
|
host: true,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://backend:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/ws': {
|
||||||
|
target: 'ws://backend:8000',
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user