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