Hub-and-Spoke Umbau: Multi-Tenant Zentrale + Satellite-Agent
- Backend: Customer/Satellite Models, customer_id auf Server/Job/Audit - Satellite-API: heartbeat, poll (atomares Claiming), logs, result, scan-result, health-report - Auth via X-Api-Key (SHA-256 gehasht) - Job-Queue: pending/claimed/running/success/failed + Stale-Janitor - Batch-Trigger: ein Job pro Server, Satellite arbeitet sequenziell ab - Credentials bleiben lokal: nur symbolische credential_ref zentral - Neues Paket satellite/: Pull-Loop, WinRM/SSH/CAU/Scanner, PyInstaller-tauglich - Frontend: Kunden-Switcher, Satelliten-View, Polling statt WebSocket - Entfernt: WebSocket/Socket.io, Redis, zentrale Credentials, JobRunner - Docs: README/AGENTS/PROMPT auf neue Architektur aktualisiert
This commit is contained in:
+10
-27
@@ -1,49 +1,38 @@
|
|||||||
# Insight Updater - Environment Template
|
# Insight Updater - Environment Template (Zentrale)
|
||||||
# Copy to .env and fill in secrets
|
# Copy to .env and fill in secrets
|
||||||
|
# Hinweis: WinRM/SSH/Credentials sind in den Satellite gewandert (satellite/config.yaml)
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# CORE APPLICATION
|
# CORE APPLICATION
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
APP_ENV=development
|
APP_ENV=development
|
||||||
SECRET_KEY=change-me-min-32-characters-random
|
SECRET_KEY=change-me-min-32-characters-random
|
||||||
ENCRYPTION_KEY=change-me-32-bytes-base64-encoded
|
|
||||||
JWT_ALGORITHM=RS256
|
JWT_ALGORITHM=RS256
|
||||||
JWT_PRIVATE_KEY_PATH=/app/keys/private.pem
|
JWT_PRIVATE_KEY_PATH=/app/keys/private.pem
|
||||||
JWT_PUBLIC_KEY_PATH=/app/keys/public.pem
|
JWT_PUBLIC_KEY_PATH=/app/keys/public.pem
|
||||||
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=30
|
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||||
JWT_REFRESH_TOKEN_EXPIRE_DAYS=7
|
JWT_REFRESH_TOKEN_EXPIRE_DAYS=7
|
||||||
|
|
||||||
|
# Initialer Admin (nur beim ersten Start, wenn keine User existieren)
|
||||||
|
ADMIN_INITIAL_PASSWORD=admin
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# DATABASE
|
# DATABASE
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Development: SQLite (file-based, zero config)
|
# Development: SQLite (file-based, zero config)
|
||||||
DATABASE_URL=sqlite+aiosqlite:///./data/app.db
|
DATABASE_URL=sqlite+aiosqlite:///./data/app.db
|
||||||
|
|
||||||
# Production: PostgreSQL (uncomment and configure)
|
# Production: PostgreSQL
|
||||||
# DATABASE_URL=postgresql+asyncpg://updater:secure-password@db:5432/insight_updater
|
# DATABASE_URL=postgresql+asyncpg://updater:secure-password@db:5432/insight_updater
|
||||||
DB_NAME=insight_updater
|
DB_NAME=insight_updater
|
||||||
DB_USER=updater
|
DB_USER=updater
|
||||||
DB_PASSWORD=change-me-db-password
|
DB_PASSWORD=change-me-db-password
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# REDIS (for Socket.io pub/sub, caching, rate limiting)
|
# JOBS
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
REDIS_URL=redis://redis:6379/0
|
# Sekunden ohne Satellite-Report, bevor ein claimed/running Job als failed gilt
|
||||||
|
JOB_STALE_TIMEOUT=3600
|
||||||
# =============================================================================
|
|
||||||
# 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 / ACTIVE DIRECTORY (STUB - prepared for future implementation)
|
||||||
@@ -54,15 +43,11 @@ LDAP_BIND_DN=CN=svc_updater,OU=Services,DC=insight,DC=local
|
|||||||
LDAP_BIND_PASSWORD=
|
LDAP_BIND_PASSWORD=
|
||||||
LDAP_USER_SEARCH_BASE=OU=Users,DC=insight,DC=local
|
LDAP_USER_SEARCH_BASE=OU=Users,DC=insight,DC=local
|
||||||
LDAP_USER_FILTER=(sAMAccountName={username})
|
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)
|
# FRONTEND (injected at build time via Vite)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
VITE_API_URL=http://localhost:8000
|
VITE_API_URL=http://localhost:8000
|
||||||
VITE_WS_URL=ws://localhost:8000
|
|
||||||
VITE_APP_TITLE=Insight Updater
|
VITE_APP_TITLE=Insight Updater
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -81,8 +66,6 @@ DOMAIN=updater.insight-it.de
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
# EXTERNAL SERVICES
|
# EXTERNAL SERVICES
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Vaultwarden (for CI/CD secrets)
|
|
||||||
VAULTWARDEN_URL=https://p.hartmannsche.cloud
|
VAULTWARDEN_URL=https://p.hartmannsche.cloud
|
||||||
# Gitea
|
|
||||||
GITEA_URL=https://gitea.insight-it.de
|
GITEA_URL=https://gitea.insight-it.de
|
||||||
GITEA_REPO=b0rbor4d/insight-updater
|
GITEA_REPO=b0rbor4d/insight-updater
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ Thumbs.db
|
|||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
|
|
||||||
|
# Satellite local config (enthaelt API-Key und Credentials!)
|
||||||
|
satellite/config.yaml
|
||||||
|
satellite/credentials.yaml
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
*.db
|
*.db
|
||||||
*.sqlite
|
*.sqlite
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
# Insight Updater - Agent Orientation
|
# Insight Updater - Agent Orientation
|
||||||
|
|
||||||
## Project Purpose
|
## 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.
|
Zentrale, mandantenfaehige Update-Orchestrierung (Hub-and-Spoke). Zentrale (Docker) +
|
||||||
|
Satelliten beim Kunden (Binary, kein Docker). Satelliten pollen Jobs, fuehren sie lokal
|
||||||
|
im Kundennetz aus (WinRM/SSH/CAU/Scan) und melden Ergebnisse zurueck.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
```bash
|
```bash
|
||||||
# Local development
|
# Zentrale lokal
|
||||||
cd ~/projects/insight-updater
|
cd ~/projects/insight-updater
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
|
|
||||||
@@ -14,137 +16,110 @@ cd backend && pip install -e . && uvicorn app.main:app --reload
|
|||||||
|
|
||||||
# Frontend only
|
# Frontend only
|
||||||
cd frontend && npm install && npm run dev
|
cd frontend && npm install && npm run dev
|
||||||
|
|
||||||
|
# Satellite (Dev, gegen lokale Zentrale)
|
||||||
|
cd satellite && pip install -e .
|
||||||
|
cp config.example.yaml config.yaml # api_key eintragen
|
||||||
|
insight-satellite
|
||||||
```
|
```
|
||||||
|
|
||||||
## Architecture Overview
|
## Architecture Overview
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
Kundennetz Zentrale
|
||||||
│ Frontend │────▶│ Backend │────▶│ Database │
|
┌────────────┐ outbound HTTPS ┌──────────────────┐
|
||||||
│ (Vue 3) │ WS │ (FastAPI) │ │ (SQLite/ │
|
│ Satellite │ ──────────────────▶ │ FastAPI Backend │
|
||||||
│ Port 3000 │◀─── │ Port 8000 │ │ PostgreSQL)│
|
│ (pollt │ X-Api-Key Auth │ /api/satellite │
|
||||||
└─────────────┘ └──────┬──────┘ └─────────────┘
|
│ alle 30s) │ ◀────────────────── │ /api (Dashboard │
|
||||||
│
|
└────────────┘ Jobs (claimed) │ JWT) + DB │
|
||||||
┌────────────┼────────────┐
|
│ WinRM/SSH/CAU lokal └──────────────────┘
|
||||||
▼ ▼ ▼
|
▼ ▲
|
||||||
┌─────────┐ ┌─────────┐ ┌─────────┐
|
Server im Kundennetz Vue 3 Frontend (JWT, Kunden-Switcher)
|
||||||
│ WinRM │ │ SSH │ │ CAU │
|
|
||||||
│ Service │ │ Service │ │ Service │
|
|
||||||
└─────────┘ └─────────┘ └─────────┘
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Key Directories
|
## Key Directories
|
||||||
|
|
||||||
| Path | Purpose |
|
| Pfad | Zweck |
|
||||||
|------|---------|
|
|------|-------|
|
||||||
| `backend/app/api/` | FastAPI route definitions (REST + WS) |
|
| `backend/app/api/routes/satellite_api.py` | Agent-API: heartbeat, poll, logs, result, scan-result, health-report |
|
||||||
| `backend/app/core/` | Config, security, database, logging |
|
| `backend/app/api/routes/` | Dashboard-REST: auth, customers, satellites, servers, updates, audit |
|
||||||
| `backend/app/models/` | SQLAlchemy ORM models |
|
| `backend/app/models/` | Customer, Satellite, Server, UpdateJob, UpdateLog, AuditLog, User |
|
||||||
| `backend/app/schemas/` | Pydantic request/response models |
|
| `backend/app/services/janitor.py` | Markiert stale Jobs (Satellite meldet nicht mehr) als failed |
|
||||||
| `backend/app/services/` | Business logic: winrm, ssh, cau, audit |
|
| `satellite/satellite/runner.py` | Main-Loop: heartbeat, poll, execute, report |
|
||||||
| `backend/app/websocket/` | Socket.io handlers for live updates |
|
| `satellite/satellite/winrm_exec.py` | Windows Update via pywinrm |
|
||||||
| `frontend/src/views/` | Page components (Dashboard, Servers, Updates, Audit) |
|
| `satellite/satellite/ssh_exec.py` | Linux Update via asyncssh |
|
||||||
| `frontend/src/components/` | Reusable UI components |
|
| `satellite/satellite/cau_exec.py` | Invoke-CauRun via WinRM |
|
||||||
| `frontend/src/stores/` | Pinia stores (auth, servers, updates) |
|
| `satellite/satellite/scanner.py` | Ping-Sweep + Port-Probe (5985/22) |
|
||||||
| `frontend/src/api/` | Axios/Socket.io client setup |
|
| `frontend/src/stores/` | Pinia: auth, customers, satellites, servers, updates |
|
||||||
|
|
||||||
## Core Models
|
## Core Models
|
||||||
|
|
||||||
| Model | Description |
|
| Model | Beschreibung |
|
||||||
|-------|-------------|
|
|-------|--------------|
|
||||||
| `Server` | Inventory item: Windows/WinRM, Linux/SSH, CAU-Cluster |
|
| `Customer` | Tenant: name, slug |
|
||||||
| `Credential` | Encrypted credentials (WinRM user/pass, SSH key/pass) |
|
| `Satellite` | Agent beim Kunden: api_key_hash, last_seen, version, hostname |
|
||||||
| `UpdateJob` | One update execution: server, status, started_by, started_at, finished_at |
|
| `Server` | Inventar pro Kunde: hostname, type, credential_ref (symbolisch!) |
|
||||||
| `UpdateLog` | Streamed log lines per job (WebSocket → DB) |
|
| `UpdateJob` | customer_id, server_id (null bei Scan), satellite_id, status, params (JSON) |
|
||||||
| `AuditLog` | Immutable audit trail: user, action, target, result |
|
| `UpdateLog` | Log-Zeilen pro Job (Batch-Upload vom Satellite) |
|
||||||
| `User` | Local admin or LDAP-mapped user |
|
| `AuditLog` | Wer, wann, was - mit customer_id |
|
||||||
|
| `User` | Dashboard-User (lokal oder LDAP-Stub) |
|
||||||
|
|
||||||
## Key Services
|
## Job Lifecycle (Pull-Modell)
|
||||||
|
|
||||||
| 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
|
|
||||||
```
|
```
|
||||||
|
pending -> claimed (beim Poll) -> running (erster Log-Push) -> success | failed
|
||||||
|
| cancelled (nur aus pending)
|
||||||
|
```
|
||||||
|
|
||||||
|
- Claiming passiert atomar im Poll (`with_for_update`), zwei Satelliten eines Kunden
|
||||||
|
bekommen nie denselben Job
|
||||||
|
- Janitor (`services/janitor.py`, alle 60s): claimed/running ohne Report seit
|
||||||
|
`JOB_STALE_TIMEOUT` (default 3600s) -> failed
|
||||||
|
- Cancel nur moeglich solange pending
|
||||||
|
|
||||||
|
## API Endpoints (Dashboard, JWT)
|
||||||
|
|
||||||
|
| Method | Path | Beschreibung |
|
||||||
|
|--------|------|--------------|
|
||||||
|
| POST | `/api/auth/login` | Login |
|
||||||
|
| GET/POST/PATCH/DELETE | `/api/customers[/{id}]` | Kunden CRUD |
|
||||||
|
| GET/POST/DELETE | `/api/satellites[/{id}]` | Satelliten CRUD |
|
||||||
|
| POST | `/api/satellites/{id}/rotate-key` | Neuer API-Key |
|
||||||
|
| GET/POST/PATCH/DELETE | `/api/servers[/{id}]` | Inventar (customer_id scoped) |
|
||||||
|
| POST | `/api/updates/trigger` | Einzelner Job |
|
||||||
|
| POST | `/api/updates/trigger-batch` | Ein Job pro Server |
|
||||||
|
| GET | `/api/updates/{id}/logs` | Job-Logs |
|
||||||
|
| GET | `/api/audit` | Audit (filterbar per customer_id) |
|
||||||
|
|
||||||
|
## API Endpoints (Satellite, X-Api-Key)
|
||||||
|
|
||||||
|
| Method | Path | Beschreibung |
|
||||||
|
|--------|------|--------------|
|
||||||
|
| POST | `/api/satellite/heartbeat` | Lebenszeichen + Version |
|
||||||
|
| GET | `/api/satellite/poll` | Pending Jobs claimen + abholen |
|
||||||
|
| POST | `/api/satellite/logs` | Log-Batch + Progress |
|
||||||
|
| POST | `/api/satellite/result` | Abschluss success/failed |
|
||||||
|
| POST | `/api/satellite/scan-result` | Gefundene Hosts (legt Server an) |
|
||||||
|
| POST | `/api/satellite/health-report` | Health-Ergebnis pro Server |
|
||||||
|
|
||||||
|
## Satellite-Deployment beim Kunden
|
||||||
|
|
||||||
|
- Binary via PyInstaller: `pyinstaller --onefile satellite/runner.py`
|
||||||
|
- `config.yaml`: central_url, api_key, poll_interval
|
||||||
|
- `credentials.yaml`: WinRM/SSH Zugangsdaten (bleiben lokal!)
|
||||||
|
- Start: Scheduled Task (Windows) oder systemd (Linux), siehe `satellite/README.md`
|
||||||
|
- 1-2 Stueck pro Kunde reichen - steuern das ganze Netz
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
- **Sprache**: Deutsch fuer User-facing Text, Englisch fuer Code/Kommentare
|
||||||
|
- **Keine Umlaute in .ps1/Python-Dateien** (ae, oe, ue, ss)
|
||||||
|
- **Keine Credentials zentral** - nur `credential_ref` Strings
|
||||||
|
- **Async**: Backend vollstaendig async; Satellite async mit to_thread fuer pywinrm
|
||||||
|
- **Types**: Strict mypy, Pydantic v2, SQLAlchemy 2.0
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
**Target**: `monitoring` (10.0.2.105)
|
**Target Zentrale**: `monitoring` (10.0.2.105)
|
||||||
**User**: `b0rbor4d` (sudo via Vaultwarden)
|
**Reverse Proxy**: Traefik (Docker labels)
|
||||||
**Reverse Proxy**: Traefik (Docker labels)
|
|
||||||
**Git Remote**: `ssh://git@gitea.insight-it.de:2222/b0rbor4d/insight-updater.git`
|
**Git Remote**: `ssh://git@gitea.insight-it.de:2222/b0rbor4d/insight-updater.git`
|
||||||
|
**Wichtig**: Zentrale muss von Kundenstandorten aus per HTTPS (443) erreichbar sein.
|
||||||
```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
|
|
||||||
|
|||||||
@@ -1,42 +1,61 @@
|
|||||||
# Insight Updater - Project Prompt
|
# Insight Updater - Project Prompt
|
||||||
|
|
||||||
## Overview
|
## 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.
|
Zentrale, mandantenfaehige Update-Orchestrierung fuer Kundennetzwerke (MSP-Modell).
|
||||||
|
Hub-and-Spoke: eine zentrale Instanz (Docker, bei Insight-IT) plus schlanke
|
||||||
|
Satelliten pro Kunde (eine Binary, kein Docker beim Kunden noetig). Satelliten pollen
|
||||||
|
Jobs von der Zentrale, fuehren Updates lokal im Kundennetz aus und melden Ergebnisse
|
||||||
|
zurueck. Dashboard zeigt alles nach Kunde sortiert.
|
||||||
|
|
||||||
## Target Stack
|
## Target Stack
|
||||||
- **Backend**: FastAPI + Python 3.11+, SQLAlchemy + SQLite/PostgreSQL, structlog, python-winrm, paramiko
|
- **Zentrale Backend**: FastAPI + Python 3.11+, SQLAlchemy + SQLite/PostgreSQL, structlog
|
||||||
- **Frontend**: Vue 3 + TypeScript + Vite, Pinia, VueUse, Tailwind CSS, Socket.io client
|
- **Zentrale Frontend**: Vue 3 + TypeScript + Vite, Pinia, Tailwind CSS
|
||||||
- **Infra**: Docker Compose (backend, frontend, db, redis), Traefik labels for reverse proxy
|
- **Satellite**: Python 3.11+, pywinrm, asyncssh, httpx, PyInstaller One-File-Binary
|
||||||
- **CI/CD**: Gitea Actions / Woodpecker CI for build & deploy to monitoring (10.0.2.105)
|
- **Infra**: Docker Compose (nur Zentrale), Traefik Labels
|
||||||
|
- **CI/CD**: Gitea Actions / Woodpecker CI fuer Build + Deploy auf monitoring (10.0.2.105)
|
||||||
|
|
||||||
|
## Architektur-Entscheidungen
|
||||||
|
- **Pull-Modell**: Satelliten pollen (default 30s). Keine eingehenden Verbindungen beim
|
||||||
|
Kunden, kein VPN, keine Firewall-Ausnahmen - nur ausgehend 443 zur Zentrale.
|
||||||
|
- **Keine Live-Daten**: Log-Batches statt WebSocket. Dashboard refresht alle 10s.
|
||||||
|
- **Credentials lokal**: WinRM/SSH-Zugangsdaten nur auf dem Satellite (credentials.yaml).
|
||||||
|
Zentral gibt es nur symbolische `credential_ref`-Namen.
|
||||||
|
- **Netz-Orchestrierung**: 1-2 Satelliten pro Kunde steuern alle Server des Kunden,
|
||||||
|
aehnlich wie CAU einen Cluster steuert. Batch-Trigger legt pro Server einen Job an,
|
||||||
|
der Satellite arbeitet sie sequenziell ab.
|
||||||
|
- **Job-Claiming atomar**: zwei Satelliten eines Kunden bekommen nie denselben Job.
|
||||||
|
|
||||||
## Core Features
|
## Core Features
|
||||||
1. **Server Inventory** - Add/edit/delete servers (Windows/WinRM, Linux/SSH, CAU-Cluster)
|
1. **Kunden + Satelliten** - CRUD, API-Key einmalig angezeigt, rotierbar
|
||||||
2. **Live Update Streaming** - WebSocket log stream with progress, status per node
|
2. **Server-Inventar pro Kunde** - manuell oder per Netzwerk-Scan (Auto-Discovery)
|
||||||
3. **CAU Cluster Orchestration** - Trigger `Invoke-CauRun`, show per-node phases
|
3. **Job-Queue** - pending/claimed/running/success/failed/cancelled + Stale-Janitor
|
||||||
4. **Linux Patch Management** - `apt/dnf/yum update` via SSH with sudo
|
4. **Windows Update** - WinRM, Microsoft.Update.Session, optional Reboot
|
||||||
5. **Audit Log** - Structured JSON logs: who, when, what server, outcome
|
5. **Linux Update** - apt/dnf/yum via SSH mit sudo, optional Reboot
|
||||||
5. **Health Checks** - `/health` endpoint, WinRM/SSH connectivity test
|
6. **CAU** - Invoke-CauRun auf Failover-Clustern
|
||||||
6. **LDAP-ready Auth** - JWT tokens, LDAP config schema prepared, local admin fallback
|
7. **Netzwerk-Scan** - Ping + Port 5985/22, legt Hosts zentral als Server an
|
||||||
|
8. **Audit-Log** - strukturiert, pro Kunde filterbar
|
||||||
|
|
||||||
## Non-Goals
|
## Non-Goals
|
||||||
- No WSUS/SCCM replacement, no approval workflows
|
- Kein WSUS/SCCM-Ersatz, keine Approval-Workflows
|
||||||
- No agent deployment (agentless WinRM/SSH only)
|
- Kein Live-Streaming (bewusst: Pull + Batches)
|
||||||
- No multi-tenancy / RBAC beyond admin/user
|
- Kein RBAC ueber admin/user hinaus
|
||||||
|
- Keine zentral gespeicherten Kunden-Credentials
|
||||||
|
|
||||||
## Success Criteria
|
## Success Criteria
|
||||||
- Add server → see "Online/Offline", last patch date
|
- Kunde anlegen -> Satellite anlegen -> API-Key einmalig angezeigt
|
||||||
- Click "Update" → live WebSocket log stream → final status Success/Failed
|
- Satellite startet -> erscheint als "online" im Dashboard (Heartbeat)
|
||||||
- CAU: Trigger cluster update, see per-node Pre/Post/Reboot phases
|
- Netzwerk-Scan -> gefundene Hosts im Inventar (discovered_by_scan)
|
||||||
- Linux: Add SSH creds, trigger update, see apt/dnf output
|
- Update triggern -> Satellite claimed Job -> Logs + Ergebnis im Dashboard
|
||||||
- `docker compose up -d` → all healthy in <5 min on fresh VM
|
- Batch: "Alle Server updaten" -> ein Job pro Server, sequenzielle Abarbeitung
|
||||||
- Deploy to monitoring (10.0.2.105) via `git push` + CI works
|
- Zwei Satelliten eines Kunden: kein Job doppelt
|
||||||
- LDAP config schema exists, service stub wired, functional later
|
- Satellite offline waehrend Job -> Janitor markiert Job nach Timeout als failed
|
||||||
|
- `docker compose up -d` -> Zentrale healthy in <5 min
|
||||||
|
|
||||||
## Verification Commands
|
## Verification Commands
|
||||||
```bash
|
```bash
|
||||||
curl -f http://localhost:8000/health
|
curl -f http://localhost:8000/health
|
||||||
curl -f http://localhost:3000/ # frontend
|
curl -f http://localhost:3000/
|
||||||
docker compose ps # all healthy
|
docker compose ps
|
||||||
```
|
```
|
||||||
|
|
||||||
## Deployment Target
|
## Deployment Target
|
||||||
@@ -44,111 +63,50 @@ docker compose ps # all healthy
|
|||||||
- **User**: b0rbor4d (sudo via Vaultwarden)
|
- **User**: b0rbor4d (sudo via Vaultwarden)
|
||||||
- **Docker**: Podman/Docker Compose v2
|
- **Docker**: Podman/Docker Compose v2
|
||||||
- **Reverse Proxy**: Traefik (labels on compose services)
|
- **Reverse Proxy**: Traefik (labels on compose services)
|
||||||
|
- **Erreichbarkeit**: HTTPS 443 von Kundenstandorten aus (ausgehend)
|
||||||
- **Git Remote**: ssh://git@gitea.insight-it.de:2222/b0rbor4d/insight-updater.git
|
- **Git Remote**: ssh://git@gitea.insight-it.de:2222/b0rbor4d/insight-updater.git
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
- Credentials encrypted at rest (Fernet/AES-GCM, key from env)
|
- Dashboard: JWT RS256, kurze Expiry
|
||||||
- WinRM: NTLM/Kerberos, HTTPS preferred, Cert validation configurable
|
- Satelliten: API-Key (ius_...), SHA-256 gehasht in DB, Prefix fuer Anzeige
|
||||||
- SSH: Key-based auth preferred, password fallback encrypted
|
- Keine Kunden-Credentials in der zentralen DB
|
||||||
- JWT: RS256, short expiry, refresh token rotation
|
- Audit-Log: append-only
|
||||||
- Audit log: immutable append-only (SQLite WAL / PG)
|
- TLS: Traefik + LetsEncrypt
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
```
|
```
|
||||||
~/projects/insight-updater/
|
~/projects/insight-updater/
|
||||||
├── backend/
|
├── backend/ # Zentrale API
|
||||||
│ ├── app/
|
│ ├── app/
|
||||||
│ │ ├── api/ # FastAPI routes
|
│ │ ├── api/routes/ # auth, customers, satellites, servers, updates, audit, satellite_api
|
||||||
│ │ ├── core/ # config, security, db
|
│ │ ├── core/ # config, security, db, logging, exceptions
|
||||||
│ │ ├── models/ # SQLAlchemy models
|
│ │ ├── models/ # Customer, Satellite, Server, UpdateJob, UpdateLog, AuditLog, User
|
||||||
│ │ ├── schemas/ # Pydantic schemas
|
│ │ ├── schemas/ # Pydantic
|
||||||
│ │ ├── services/ # business logic (winrm, ssh, cau, audit)
|
│ │ ├── services/ # audit, janitor
|
||||||
│ │ ├── websocket/ # Socket.io / FastAPI WS handlers
|
|
||||||
│ │ └── main.py
|
│ │ └── main.py
|
||||||
│ ├── tests/
|
|
||||||
│ ├── Dockerfile
|
│ ├── Dockerfile
|
||||||
│ ├── requirements.txt
|
|
||||||
│ └── pyproject.toml
|
│ └── pyproject.toml
|
||||||
├── frontend/
|
├── frontend/ # Dashboard
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
│ │ ├── components/
|
│ │ ├── views/ # Dashboard, Customers, Satellites, Servers, Updates, Audit, Login
|
||||||
│ │ ├── views/
|
│ │ ├── stores/ # auth, customers, satellites, servers, updates
|
||||||
│ │ ├── stores/
|
│ │ └── components/ # AppLayout (mit Kunden-Switcher)
|
||||||
│ │ ├── api/
|
|
||||||
│ │ └── main.ts
|
|
||||||
│ ├── Dockerfile
|
│ ├── Dockerfile
|
||||||
│ ├── package.json
|
│ └── package.json
|
||||||
│ └── vite.config.ts
|
├── satellite/ # Remote-Agent
|
||||||
|
│ ├── satellite/
|
||||||
|
│ │ ├── runner.py # Main-Loop
|
||||||
|
│ │ ├── client.py # Zentral-API-Client
|
||||||
|
│ │ ├── config.py # config.yaml + credentials.yaml
|
||||||
|
│ │ ├── winrm_exec.py, ssh_exec.py, cau_exec.py, scanner.py
|
||||||
|
│ ├── config.example.yaml
|
||||||
|
│ ├── credentials.example.yaml
|
||||||
|
│ └── pyproject.toml
|
||||||
├── docker-compose.yml
|
├── docker-compose.yml
|
||||||
├── docker-compose.prod.yml
|
├── docker-compose.prod.yml
|
||||||
├── .env.example
|
└── .env.example
|
||||||
├── .gitignore
|
|
||||||
├── README.md
|
|
||||||
├── AGENTS.md
|
|
||||||
└── PROMPT.md
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Key Libraries
|
## Environment Variables
|
||||||
- `fastapi`, `uvicorn`, `sqlalchemy[asyncio]`, `alembic`
|
Zentrale siehe `.env.example`: SECRET_KEY, DATABASE_URL, JWT-Keys, LDAP-Stub.
|
||||||
- `python-winrm[kerberos]`, `paramiko`, `asyncssh`
|
Satellite siehe `satellite/config.example.yaml`: central_url, api_key, poll_interval.
|
||||||
- `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
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -1,172 +1,115 @@
|
|||||||
# Insight Updater
|
# 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.
|
Zentrale, mandantenfaehige Update-Orchestrierung fuer Kundennetzwerke. Hub-and-Spoke:
|
||||||
|
eine zentrale Instanz (Docker) plus schlanke Satelliten beim Kunden (einzelne Binary,
|
||||||
|
kein Docker noetig). Satelliten pollen Jobs von der Zentrale, fuehren Updates lokal
|
||||||
|
im Kundennetz aus (WinRM / SSH / CAU) und melden Ergebnisse zurueck.
|
||||||
|
|
||||||
|
## Architektur
|
||||||
|
|
||||||
|
```
|
||||||
|
Kundennetz A Kundennetz B Zentrale
|
||||||
|
┌──────────────┐ ┌──────────────┐ ┌───────────────┐
|
||||||
|
│ Satellite 1 │ │ Satellite 1 │ outbound │ Backend │
|
||||||
|
│ (Binary) │───┐ │ (Binary) │───┐ HTTPS │ (FastAPI) │
|
||||||
|
└──────────────┘ │ └──────────────┘ │────────────▶│ + DB │
|
||||||
|
┌──────────────┐ │ ┌──────────────┐ │ │ + Frontend │
|
||||||
|
│ Satellite 2 │───┘ │ Satellite 2 │───┘ │ (Dashboard, │
|
||||||
|
└──────────────┘ WinRM/SSH └──────────────┘ │ nach Kunde) │
|
||||||
|
lokal └───────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Pull-Modell**: Satelliten fragen alle N Sekunden nach Jobs - keine eingehenden Verbindungen beim Kunden noetig
|
||||||
|
- **Multi-Tenant**: jede Entitaet (Server, Jobs, Satelliten, Audit) haengt an einem Kunden
|
||||||
|
- **Credentials bleiben lokal**: WinRM/SSH-Zugangsdaten liegen nur auf dem Satellite (`credentials.yaml`), niemals zentral
|
||||||
|
- **Netz-Orchestrierung**: 1-2 Satelliten pro Kunde steuern das ganze Netz, aehnlich CAU im Cluster
|
||||||
|
|
||||||
|
## Komponenten
|
||||||
|
|
||||||
|
| Teil | Pfad | Technologie |
|
||||||
|
|------|------|-------------|
|
||||||
|
| Zentrale (Backend) | `backend/` | FastAPI, SQLAlchemy 2.0, SQLite/PostgreSQL |
|
||||||
|
| Zentrale (Frontend) | `frontend/` | Vue 3, TypeScript, Vite, Pinia, Tailwind |
|
||||||
|
| Satellite (Agent) | `satellite/` | Python, pywinrm, asyncssh, httpx, PyInstaller |
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Server Inventory** — Windows/WinRM, Linux/SSH, CAU Clusters
|
- **Kunden-Verwaltung** mit Satelliten pro Kunde (API-Key, einmalig angezeigt)
|
||||||
- **Live Updates** — WebSocket log stream with progress per node
|
- **Server-Inventar** pro Kunde, inkl. Auto-Discovery per Netzwerk-Scan
|
||||||
- **CAU Support** — Trigger `Invoke-CauRun`, track per-node phases
|
- **Job-Queue**: pending - claimed - running - success/failed, mit Stale-Janitor
|
||||||
- **Linux Patching** — `apt/dnf/yum update` via SSH with sudo
|
- **Batch-Trigger**: Update-Jobs fuer alle Server eines Kunden auf einmal
|
||||||
- **Audit Log** — Structured JSON: who, when, what server, outcome
|
- **Netzwerk-Scan**: Ping + Port-Probe (5985/22), legt gefundene Hosts zentral an
|
||||||
- **Health Checks** — WinRM/SSH connectivity test
|
- **Log-Upload** in Batches (kein Live-Stream noetig, 10s Dashboard-Refresh)
|
||||||
- **LDAP Ready** — Config schema + stub for Active Directory auth
|
- **Audit-Log**: strukturiert, pro Kunde filterbar
|
||||||
|
- **Auth**: JWT (RS256) fuer Dashboard-User, API-Key (SHA-256 gehasht) fuer Satelliten
|
||||||
|
|
||||||
## Quick Start (Development)
|
## Quick Start (Zentrale, Development)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Clone and enter
|
|
||||||
git clone ssh://git@gitea.insight-it.de:2222/b0rbor4d/insight-updater.git
|
git clone ssh://git@gitea.insight-it.de:2222/b0rbor4d/insight-updater.git
|
||||||
cd insight-updater
|
cd insight-updater
|
||||||
|
|
||||||
# Configure environment
|
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# Edit .env with your secrets
|
|
||||||
|
|
||||||
# Start all services
|
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
|
|
||||||
# Access
|
|
||||||
# Frontend: http://localhost:3000
|
# Frontend: http://localhost:3000
|
||||||
# Backend API: http://localhost:8000
|
# Backend API: http://localhost:8000/docs
|
||||||
# API Docs: http://localhost:8000/docs
|
# Login: admin / admin (bzw. ADMIN_INITIAL_PASSWORD)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Satellite beim Kunden
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd satellite
|
||||||
|
pip install -e .
|
||||||
|
cp config.example.yaml config.yaml # central_url + api_key eintragen
|
||||||
|
cp credentials.example.yaml credentials.yaml # lokale Zugangsdaten
|
||||||
|
insight-satellite
|
||||||
|
```
|
||||||
|
|
||||||
|
Produktiv als Windows-Binary: `pyinstaller --onefile satellite/runner.py`, Details in `satellite/README.md`.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. Kunde anlegen (Dashboard - Kunden)
|
||||||
|
2. Satellite anlegen - API-Key wird einmalig angezeigt
|
||||||
|
3. Satellite beim Kunden installieren (Binary + config.yaml + credentials.yaml)
|
||||||
|
4. Netzwerk-Scan starten - gefundene Hosts landen im Inventar
|
||||||
|
5. Updates triggern: einzeln, per Batch oder ganzer Kunde
|
||||||
|
6. Ergebnisse im Dashboard (nach Kunde sortiert) pruefen
|
||||||
|
|
||||||
## Production Deployment (monitoring.insight.local)
|
## Production Deployment (monitoring.insight.local)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# On monitoring host (10.0.2.105)
|
|
||||||
git clone ssh://git@gitea.insight-it.de:2222/b0rbor4d/insight-updater.git
|
git clone ssh://git@gitea.insight-it.de:2222/b0rbor4d/insight-updater.git
|
||||||
cd insight-updater
|
cd insight-updater
|
||||||
|
cp .env.example .env # Secrets fuellen
|
||||||
# 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
|
mkdir -p keys
|
||||||
openssl genrsa -out keys/private.pem 2048
|
openssl genrsa -out keys/private.pem 2048
|
||||||
openssl rsa -in keys/private.pem -pubout -out keys/public.pem
|
openssl rsa -in keys/private.pem -pubout -out keys/public.pem
|
||||||
|
|
||||||
# Deploy
|
|
||||||
docker compose -f docker-compose.prod.yml up -d --build
|
docker compose -f docker-compose.prod.yml up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
## Architecture
|
Wichtig: die Zentrale muss per HTTPS von den Kunden-Standorten aus erreichbar sein
|
||||||
|
(ausgehend 443 reicht). Traefik-Labels sind vorbereitet.
|
||||||
|
|
||||||
```
|
## Umgebungsvariablen
|
||||||
┌─────────────┐ WebSocket ┌─────────────┐
|
|
||||||
│ Frontend │ ◀─────────────▶ │ Backend │
|
|
||||||
│ (Vue 3) │ REST + WS │ (FastAPI) │
|
|
||||||
└─────────────┘ └──────┬──────┘
|
|
||||||
│
|
|
||||||
┌──────────────────┼──────────────────┐
|
|
||||||
▼ ▼ ▼
|
|
||||||
┌───────────┐ ┌───────────┐ ┌───────────┐
|
|
||||||
│ WinRM │ │ SSH │ │ CAU │
|
|
||||||
│ Service │ │ Service │ │ Service │
|
|
||||||
└───────────┘ └───────────┘ └───────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## Tech Stack
|
Siehe `.env.example`. Zentrale braucht nur noch: SECRET_KEY, DATABASE_URL, JWT-Keys,
|
||||||
|
optional LDAP. WinRM/SSH-Config ist in den Satellite gewandert (dessen `config.yaml`).
|
||||||
|
|
||||||
| Layer | Technology |
|
## Development
|
||||||
|-------|------------|
|
|
||||||
| 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
|
```bash
|
||||||
# Backend
|
# Backend
|
||||||
cd backend
|
cd backend && pip install -e . && uvicorn app.main:app --reload
|
||||||
pip install -e .
|
|
||||||
uvicorn app.main:app --reload
|
|
||||||
|
|
||||||
# Run tests
|
|
||||||
pytest -v
|
|
||||||
|
|
||||||
# Lint
|
|
||||||
ruff check .
|
|
||||||
mypy .
|
|
||||||
|
|
||||||
# Frontend
|
# Frontend
|
||||||
cd frontend
|
cd frontend && npm install && npm run dev
|
||||||
npm install
|
|
||||||
npm run dev
|
|
||||||
|
|
||||||
# Build
|
# Satellite (lokaler Test gegen Dev-Zentrale)
|
||||||
npm run build
|
cd satellite && pip install -e . && insight-satellite
|
||||||
|
|
||||||
# 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
|
## License
|
||||||
|
|
||||||
MIT — Insight IT
|
MIT - Insight IT
|
||||||
|
|||||||
@@ -2,10 +2,13 @@
|
|||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from app.api.routes import audit, auth, servers, updates
|
from app.api.routes import audit, auth, customers, satellite_api, satellites, servers, updates
|
||||||
|
|
||||||
api_router = APIRouter(prefix="/api")
|
api_router = APIRouter(prefix="/api")
|
||||||
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
|
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
|
||||||
|
api_router.include_router(customers.router, prefix="/customers", tags=["customers"])
|
||||||
|
api_router.include_router(satellites.router, prefix="/satellites", tags=["satellites"])
|
||||||
api_router.include_router(servers.router, prefix="/servers", tags=["servers"])
|
api_router.include_router(servers.router, prefix="/servers", tags=["servers"])
|
||||||
api_router.include_router(updates.router, prefix="/updates", tags=["updates"])
|
api_router.include_router(updates.router, prefix="/updates", tags=["updates"])
|
||||||
api_router.include_router(audit.router, prefix="/audit", tags=["audit"])
|
api_router.include_router(audit.router, prefix="/audit", tags=["audit"])
|
||||||
|
api_router.include_router(satellite_api.router, prefix="/satellite", tags=["satellite-api"])
|
||||||
|
|||||||
+20
-4
@@ -1,12 +1,14 @@
|
|||||||
"""Shared API dependencies: current user extraction from JWT."""
|
"""Shared API dependencies: dashboard user auth (JWT) and satellite auth (API key)."""
|
||||||
|
|
||||||
from fastapi import Depends, Request
|
from fastapi import Depends, Header, Request
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.exceptions import ForbiddenError, UnauthorizedError
|
from app.core.exceptions import ForbiddenError, UnauthorizedError
|
||||||
from app.core.security import decode_token
|
from app.core.security import decode_token
|
||||||
|
from app.models.satellite import Satellite, hash_api_key
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
|
||||||
bearer_scheme = HTTPBearer(auto_error=False)
|
bearer_scheme = HTTPBearer(auto_error=False)
|
||||||
@@ -23,8 +25,6 @@ async def get_current_user(
|
|||||||
if not username:
|
if not username:
|
||||||
raise UnauthorizedError("Token enthält keinen Benutzer")
|
raise UnauthorizedError("Token enthält keinen Benutzer")
|
||||||
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
result = await db.execute(select(User).where(User.username == username))
|
result = await db.execute(select(User).where(User.username == username))
|
||||||
user = result.scalar_one_or_none()
|
user = result.scalar_one_or_none()
|
||||||
if not user or not user.is_active:
|
if not user or not user.is_active:
|
||||||
@@ -38,5 +38,21 @@ async def require_admin(user: User = Depends(get_current_user)) -> User:
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_satellite(
|
||||||
|
x_api_key: str | None = Header(default=None),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> Satellite:
|
||||||
|
"""Authenticate a satellite by its API key (X-Api-Key header)."""
|
||||||
|
if not x_api_key:
|
||||||
|
raise UnauthorizedError("X-Api-Key header fehlt")
|
||||||
|
result = await db.execute(
|
||||||
|
select(Satellite).where(Satellite.api_key_hash == hash_api_key(x_api_key))
|
||||||
|
)
|
||||||
|
satellite = result.scalar_one_or_none()
|
||||||
|
if not satellite or not satellite.is_active:
|
||||||
|
raise UnauthorizedError("Satellite unbekannt oder deaktiviert")
|
||||||
|
return satellite
|
||||||
|
|
||||||
|
|
||||||
def client_ip(request: Request) -> str | None:
|
def client_ip(request: Request) -> str | None:
|
||||||
return request.client.host if request.client else None
|
return request.client.host if request.client else None
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ async def list_audit_logs(
|
|||||||
page_size: int = Query(default=50, ge=1, le=200),
|
page_size: int = Query(default=50, ge=1, le=200),
|
||||||
action: str | None = None,
|
action: str | None = None,
|
||||||
username: str | None = None,
|
username: str | None = None,
|
||||||
|
customer_id: int | None = None,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
_admin: User = Depends(require_admin),
|
_admin: User = Depends(require_admin),
|
||||||
) -> AuditLogPage:
|
) -> AuditLogPage:
|
||||||
@@ -31,6 +32,9 @@ async def list_audit_logs(
|
|||||||
if username:
|
if username:
|
||||||
stmt = stmt.where(AuditLog.username == username)
|
stmt = stmt.where(AuditLog.username == username)
|
||||||
count_stmt = count_stmt.where(AuditLog.username == username)
|
count_stmt = count_stmt.where(AuditLog.username == username)
|
||||||
|
if customer_id is not None:
|
||||||
|
stmt = stmt.where(AuditLog.customer_id == customer_id)
|
||||||
|
count_stmt = count_stmt.where(AuditLog.customer_id == customer_id)
|
||||||
|
|
||||||
total = await db.scalar(count_stmt) or 0
|
total = await db.scalar(count_stmt) or 0
|
||||||
stmt = stmt.offset((page - 1) * page_size).limit(page_size)
|
stmt = stmt.offset((page - 1) * page_size).limit(page_size)
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"""Customer routes (tenant management)."""
|
||||||
|
|
||||||
|
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 ConflictError, NotFoundError
|
||||||
|
from app.models.customer import Customer
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.customer import CustomerCreate, CustomerRead, CustomerUpdate
|
||||||
|
from app.services.audit import AuditService
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[CustomerRead])
|
||||||
|
async def list_customers(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: User = Depends(get_current_user),
|
||||||
|
) -> list[Customer]:
|
||||||
|
result = await db.execute(select(Customer).order_by(Customer.name))
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=CustomerRead, status_code=201)
|
||||||
|
async def create_customer(
|
||||||
|
payload: CustomerCreate,
|
||||||
|
request: Request,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
) -> Customer:
|
||||||
|
existing = await db.execute(
|
||||||
|
select(Customer).where((Customer.name == payload.name) | (Customer.slug == payload.slug))
|
||||||
|
)
|
||||||
|
if existing.scalar_one_or_none():
|
||||||
|
raise ConflictError("Kunde mit diesem Namen oder Slug existiert bereits")
|
||||||
|
|
||||||
|
customer = Customer(**payload.model_dump())
|
||||||
|
db.add(customer)
|
||||||
|
await db.flush()
|
||||||
|
await AuditService(db).log(
|
||||||
|
username=user.username,
|
||||||
|
action="customer.create",
|
||||||
|
target=customer.name,
|
||||||
|
customer_id=customer.id,
|
||||||
|
ip_address=client_ip(request),
|
||||||
|
)
|
||||||
|
return customer
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{customer_id}", response_model=CustomerRead)
|
||||||
|
async def get_customer(
|
||||||
|
customer_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: User = Depends(get_current_user),
|
||||||
|
) -> Customer:
|
||||||
|
customer = await db.get(Customer, customer_id)
|
||||||
|
if not customer:
|
||||||
|
raise NotFoundError("Kunde nicht gefunden")
|
||||||
|
return customer
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{customer_id}", response_model=CustomerRead)
|
||||||
|
async def update_customer(
|
||||||
|
customer_id: int,
|
||||||
|
payload: CustomerUpdate,
|
||||||
|
request: Request,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
) -> Customer:
|
||||||
|
customer = await db.get(Customer, customer_id)
|
||||||
|
if not customer:
|
||||||
|
raise NotFoundError("Kunde nicht gefunden")
|
||||||
|
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||||
|
setattr(customer, field, value)
|
||||||
|
await AuditService(db).log(
|
||||||
|
username=user.username,
|
||||||
|
action="customer.update",
|
||||||
|
target=customer.name,
|
||||||
|
customer_id=customer.id,
|
||||||
|
ip_address=client_ip(request),
|
||||||
|
)
|
||||||
|
return customer
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{customer_id}", status_code=204)
|
||||||
|
async def delete_customer(
|
||||||
|
customer_id: int,
|
||||||
|
request: Request,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
) -> None:
|
||||||
|
customer = await db.get(Customer, customer_id)
|
||||||
|
if not customer:
|
||||||
|
raise NotFoundError("Kunde nicht gefunden")
|
||||||
|
await AuditService(db).log(
|
||||||
|
username=user.username,
|
||||||
|
action="customer.delete",
|
||||||
|
target=customer.name,
|
||||||
|
customer_id=customer.id,
|
||||||
|
ip_address=client_ip(request),
|
||||||
|
)
|
||||||
|
await db.delete(customer)
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
"""Satellite agent API - polled by remote satellites, authenticated via X-Api-Key.
|
||||||
|
|
||||||
|
Pull model: satellites poll for jobs, execute them locally in the customer
|
||||||
|
network, push log batches and final results back here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import get_current_satellite
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.exceptions import ForbiddenError, NotFoundError
|
||||||
|
from app.core.logging import get_logger
|
||||||
|
from app.models.satellite import Satellite
|
||||||
|
from app.models.server import Server, ServerType
|
||||||
|
from app.models.update_job import JobStatus, JobType, UpdateJob, UpdateLog
|
||||||
|
from app.schemas.satellite_api import (
|
||||||
|
HealthReportRequest,
|
||||||
|
HeartbeatRequest,
|
||||||
|
JobResultRequest,
|
||||||
|
LogBatchRequest,
|
||||||
|
PollResponse,
|
||||||
|
SatelliteJob,
|
||||||
|
ScanResultRequest,
|
||||||
|
)
|
||||||
|
from app.services.audit import AuditService
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/heartbeat")
|
||||||
|
async def heartbeat(
|
||||||
|
payload: HeartbeatRequest,
|
||||||
|
satellite: Satellite = Depends(get_current_satellite),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
satellite.last_seen_at = datetime.now(UTC)
|
||||||
|
satellite.version = payload.version
|
||||||
|
satellite.hostname = payload.hostname
|
||||||
|
return {"ok": True, "server_time": datetime.now(UTC).isoformat()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/poll", response_model=PollResponse)
|
||||||
|
async def poll_jobs(
|
||||||
|
satellite: Satellite = Depends(get_current_satellite),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> PollResponse:
|
||||||
|
"""Claim and return pending jobs for this satellite's customer.
|
||||||
|
|
||||||
|
Claiming is atomic-ish: status flips pending -> claimed in the same
|
||||||
|
transaction, so two satellites of one customer do not get the same job.
|
||||||
|
"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(UpdateJob)
|
||||||
|
.where(
|
||||||
|
UpdateJob.customer_id == satellite.customer_id,
|
||||||
|
UpdateJob.status == JobStatus.PENDING,
|
||||||
|
)
|
||||||
|
.order_by(UpdateJob.id)
|
||||||
|
.limit(5)
|
||||||
|
.with_for_update()
|
||||||
|
)
|
||||||
|
jobs = list(result.scalars().all())
|
||||||
|
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
out: list[SatelliteJob] = []
|
||||||
|
for job in jobs:
|
||||||
|
job.status = JobStatus.CLAIMED
|
||||||
|
job.satellite_id = satellite.id
|
||||||
|
job.claimed_at = now
|
||||||
|
job.last_report_at = now
|
||||||
|
|
||||||
|
params = json.loads(job.params) if job.params else {}
|
||||||
|
server = job.server
|
||||||
|
out.append(
|
||||||
|
SatelliteJob(
|
||||||
|
job_id=job.id,
|
||||||
|
type=job.type,
|
||||||
|
server_id=server.id if server else None,
|
||||||
|
server_name=server.name if server else None,
|
||||||
|
hostname=server.hostname if server else None,
|
||||||
|
port=server.port if server else None,
|
||||||
|
server_type=server.type.value if server else None,
|
||||||
|
credential_ref=server.credential_ref if server else None,
|
||||||
|
reboot_if_required=bool(params.get("reboot_if_required", False)),
|
||||||
|
scan_subnet=params.get("scan_subnet"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if out:
|
||||||
|
logger.info(
|
||||||
|
"satellite.jobs_claimed",
|
||||||
|
satellite=satellite.name,
|
||||||
|
customer_id=satellite.customer_id,
|
||||||
|
count=len(out),
|
||||||
|
)
|
||||||
|
return PollResponse(jobs=out)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logs")
|
||||||
|
async def push_logs(
|
||||||
|
payload: LogBatchRequest,
|
||||||
|
satellite: Satellite = Depends(get_current_satellite),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
job = await _get_own_job(db, payload.job_id, satellite)
|
||||||
|
|
||||||
|
if job.status == JobStatus.CLAIMED:
|
||||||
|
job.status = JobStatus.RUNNING
|
||||||
|
job.started_at = datetime.now(UTC)
|
||||||
|
|
||||||
|
for line in payload.lines:
|
||||||
|
db.add(
|
||||||
|
UpdateLog(
|
||||||
|
job_id=job.id,
|
||||||
|
timestamp=line.timestamp,
|
||||||
|
level=line.level,
|
||||||
|
line=line.line,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if payload.progress_percent is not None:
|
||||||
|
job.progress_percent = payload.progress_percent
|
||||||
|
if payload.current_phase is not None:
|
||||||
|
job.current_phase = payload.current_phase
|
||||||
|
job.last_report_at = datetime.now(UTC)
|
||||||
|
|
||||||
|
return {"ok": True, "accepted": len(payload.lines)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/result")
|
||||||
|
async def push_result(
|
||||||
|
payload: JobResultRequest,
|
||||||
|
satellite: Satellite = Depends(get_current_satellite),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
job = await _get_own_job(db, payload.job_id, satellite)
|
||||||
|
|
||||||
|
job.status = JobStatus.SUCCESS if payload.status == "success" else JobStatus.FAILED
|
||||||
|
job.error = payload.error
|
||||||
|
job.finished_at = datetime.now(UTC)
|
||||||
|
job.last_report_at = job.finished_at
|
||||||
|
if job.status == JobStatus.SUCCESS:
|
||||||
|
job.progress_percent = 100
|
||||||
|
|
||||||
|
await AuditService(db).log(
|
||||||
|
username=f"satellite:{satellite.name}",
|
||||||
|
action="job.result",
|
||||||
|
target=f"job:{job.id}",
|
||||||
|
result="success" if payload.status == "success" else "failure",
|
||||||
|
customer_id=satellite.customer_id,
|
||||||
|
details={"type": job.type.value, "error": payload.error},
|
||||||
|
)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/scan-result")
|
||||||
|
async def push_scan_result(
|
||||||
|
payload: ScanResultRequest,
|
||||||
|
satellite: Satellite = Depends(get_current_satellite),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""Ingest discovered hosts from a NETWORK_SCAN job as server candidates."""
|
||||||
|
job = await _get_own_job(db, payload.job_id, satellite)
|
||||||
|
if job.type != JobType.NETWORK_SCAN:
|
||||||
|
raise ForbiddenError("Scan-Ergebnisse nur für NETWORK_SCAN Jobs")
|
||||||
|
|
||||||
|
created = 0
|
||||||
|
for host in payload.hosts:
|
||||||
|
existing = await db.execute(
|
||||||
|
select(Server).where(
|
||||||
|
Server.customer_id == satellite.customer_id,
|
||||||
|
Server.hostname.in_([host.hostname, host.ip]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing.scalar_one_or_none():
|
||||||
|
continue
|
||||||
|
|
||||||
|
if host.winrm_open:
|
||||||
|
stype, port = ServerType.WINDOWS, 5985
|
||||||
|
elif host.ssh_open:
|
||||||
|
stype, port = ServerType.LINUX, 22
|
||||||
|
else:
|
||||||
|
continue # not manageable - skip
|
||||||
|
|
||||||
|
db.add(
|
||||||
|
Server(
|
||||||
|
customer_id=satellite.customer_id,
|
||||||
|
name=host.hostname,
|
||||||
|
hostname=host.ip,
|
||||||
|
port=port,
|
||||||
|
type=stype,
|
||||||
|
description=f"Auto-Discovery via Scan (Job #{job.id})",
|
||||||
|
discovered_by_scan=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
created += 1
|
||||||
|
|
||||||
|
return {"ok": True, "created": created}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/health-report")
|
||||||
|
async def push_health_report(
|
||||||
|
payload: HealthReportRequest,
|
||||||
|
satellite: Satellite = Depends(get_current_satellite),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
server = await db.get(Server, payload.server_id)
|
||||||
|
if not server or server.customer_id != satellite.customer_id:
|
||||||
|
raise NotFoundError("Server nicht gefunden")
|
||||||
|
|
||||||
|
server.last_health_at = datetime.now(UTC)
|
||||||
|
server.last_health_ok = payload.ok
|
||||||
|
server.last_health_message = payload.message
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_own_job(
|
||||||
|
db: AsyncSession, job_id: int, satellite: Satellite
|
||||||
|
) -> UpdateJob:
|
||||||
|
job = await db.get(UpdateJob, job_id)
|
||||||
|
if not job or job.customer_id != satellite.customer_id:
|
||||||
|
raise NotFoundError("Job nicht gefunden")
|
||||||
|
return job
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
"""Satellite management routes (dashboard side).
|
||||||
|
|
||||||
|
The plaintext API key is returned exactly once on creation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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.customer import Customer
|
||||||
|
from app.models.satellite import Satellite, generate_api_key, hash_api_key
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.satellite import SatelliteCreate, SatelliteCreated, SatelliteRead
|
||||||
|
from app.services.audit import AuditService
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _created_response(satellite: Satellite, api_key: str) -> SatelliteCreated:
|
||||||
|
data = SatelliteRead.model_validate(satellite).model_dump()
|
||||||
|
return SatelliteCreated(**data, api_key=api_key)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[SatelliteRead])
|
||||||
|
async def list_satellites(
|
||||||
|
customer_id: int | None = None,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: User = Depends(get_current_user),
|
||||||
|
) -> list[Satellite]:
|
||||||
|
stmt = select(Satellite).order_by(Satellite.id)
|
||||||
|
if customer_id is not None:
|
||||||
|
stmt = stmt.where(Satellite.customer_id == customer_id)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=SatelliteCreated, status_code=201)
|
||||||
|
async def create_satellite(
|
||||||
|
payload: SatelliteCreate,
|
||||||
|
request: Request,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
) -> SatelliteCreated:
|
||||||
|
customer = await db.get(Customer, payload.customer_id)
|
||||||
|
if not customer:
|
||||||
|
raise NotFoundError("Kunde nicht gefunden")
|
||||||
|
|
||||||
|
api_key = generate_api_key()
|
||||||
|
satellite = Satellite(
|
||||||
|
customer_id=payload.customer_id,
|
||||||
|
name=payload.name,
|
||||||
|
api_key_hash=hash_api_key(api_key),
|
||||||
|
api_key_prefix=api_key[:11],
|
||||||
|
)
|
||||||
|
db.add(satellite)
|
||||||
|
await db.flush()
|
||||||
|
await AuditService(db).log(
|
||||||
|
username=user.username,
|
||||||
|
action="satellite.create",
|
||||||
|
target=f"{customer.name}/{satellite.name}",
|
||||||
|
customer_id=customer.id,
|
||||||
|
ip_address=client_ip(request),
|
||||||
|
)
|
||||||
|
return _created_response(satellite, api_key)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{satellite_id}", status_code=204)
|
||||||
|
async def delete_satellite(
|
||||||
|
satellite_id: int,
|
||||||
|
request: Request,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
) -> None:
|
||||||
|
satellite = await db.get(Satellite, satellite_id)
|
||||||
|
if not satellite:
|
||||||
|
raise NotFoundError("Satellite nicht gefunden")
|
||||||
|
await AuditService(db).log(
|
||||||
|
username=user.username,
|
||||||
|
action="satellite.delete",
|
||||||
|
target=satellite.name,
|
||||||
|
customer_id=satellite.customer_id,
|
||||||
|
ip_address=client_ip(request),
|
||||||
|
)
|
||||||
|
await db.delete(satellite)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{satellite_id}/rotate-key", response_model=SatelliteCreated)
|
||||||
|
async def rotate_satellite_key(
|
||||||
|
satellite_id: int,
|
||||||
|
request: Request,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
) -> SatelliteCreated:
|
||||||
|
satellite = await db.get(Satellite, satellite_id)
|
||||||
|
if not satellite:
|
||||||
|
raise NotFoundError("Satellite nicht gefunden")
|
||||||
|
|
||||||
|
api_key = generate_api_key()
|
||||||
|
satellite.api_key_hash = hash_api_key(api_key)
|
||||||
|
satellite.api_key_prefix = api_key[:11]
|
||||||
|
await AuditService(db).log(
|
||||||
|
username=user.username,
|
||||||
|
action="satellite.rotate_key",
|
||||||
|
target=satellite.name,
|
||||||
|
customer_id=satellite.customer_id,
|
||||||
|
ip_address=client_ip(request),
|
||||||
|
)
|
||||||
|
return _created_response(satellite, api_key)
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
"""Server inventory routes."""
|
"""Server inventory routes (customer-scoped).
|
||||||
|
|
||||||
from datetime import UTC, datetime
|
No direct connectivity from the central server - health checks are
|
||||||
|
HEALTH_CHECK jobs executed by the customer's satellite.
|
||||||
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Request
|
from fastapi import APIRouter, Depends, Request
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
@@ -8,26 +10,26 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.api.deps import client_ip, get_current_user
|
from app.api.deps import client_ip, get_current_user
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.exceptions import NotFoundError
|
from app.core.exceptions import ConflictError, NotFoundError
|
||||||
from app.models.credential import Credential
|
from app.models.customer import Customer
|
||||||
from app.models.server import Server, ServerType
|
from app.models.server import Server
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.server import HealthCheckResult, ServerCreate, ServerRead, ServerUpdate
|
from app.schemas.server import ServerCreate, ServerRead, ServerUpdate
|
||||||
from app.services.audit import AuditService
|
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 = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[ServerRead])
|
@router.get("", response_model=list[ServerRead])
|
||||||
async def list_servers(
|
async def list_servers(
|
||||||
|
customer_id: int | None = None,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
_user: User = Depends(get_current_user),
|
_user: User = Depends(get_current_user),
|
||||||
) -> list[Server]:
|
) -> list[Server]:
|
||||||
result = await db.execute(select(Server).order_by(Server.name))
|
stmt = select(Server).order_by(Server.name)
|
||||||
|
if customer_id is not None:
|
||||||
|
stmt = stmt.where(Server.customer_id == customer_id)
|
||||||
|
result = await db.execute(stmt)
|
||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
@@ -38,6 +40,18 @@ async def create_server(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
) -> Server:
|
) -> Server:
|
||||||
|
customer = await db.get(Customer, payload.customer_id)
|
||||||
|
if not customer:
|
||||||
|
raise NotFoundError("Kunde nicht gefunden")
|
||||||
|
|
||||||
|
existing = await db.execute(
|
||||||
|
select(Server).where(
|
||||||
|
Server.customer_id == payload.customer_id, Server.name == payload.name
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing.scalar_one_or_none():
|
||||||
|
raise ConflictError("Server mit diesem Namen existiert beim Kunden bereits")
|
||||||
|
|
||||||
server = Server(**payload.model_dump())
|
server = Server(**payload.model_dump())
|
||||||
db.add(server)
|
db.add(server)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
@@ -45,6 +59,7 @@ async def create_server(
|
|||||||
username=user.username,
|
username=user.username,
|
||||||
action="server.create",
|
action="server.create",
|
||||||
target=server.name,
|
target=server.name,
|
||||||
|
customer_id=customer.id,
|
||||||
ip_address=client_ip(request),
|
ip_address=client_ip(request),
|
||||||
)
|
)
|
||||||
return server
|
return server
|
||||||
@@ -79,6 +94,7 @@ async def update_server(
|
|||||||
username=user.username,
|
username=user.username,
|
||||||
action="server.update",
|
action="server.update",
|
||||||
target=server.name,
|
target=server.name,
|
||||||
|
customer_id=server.customer_id,
|
||||||
ip_address=client_ip(request),
|
ip_address=client_ip(request),
|
||||||
)
|
)
|
||||||
return server
|
return server
|
||||||
@@ -98,63 +114,7 @@ async def delete_server(
|
|||||||
username=user.username,
|
username=user.username,
|
||||||
action="server.delete",
|
action="server.delete",
|
||||||
target=server.name,
|
target=server.name,
|
||||||
|
customer_id=server.customer_id,
|
||||||
ip_address=client_ip(request),
|
ip_address=client_ip(request),
|
||||||
)
|
)
|
||||||
await db.delete(server)
|
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,
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
"""Update job routes: trigger, list, logs, cancel."""
|
"""Update job routes: trigger (single + batch), list, logs, cancel.
|
||||||
|
|
||||||
|
Triggering only queues a job - a satellite of that customer picks it up
|
||||||
|
on its next poll and executes it locally.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query, Request
|
from fastapi import APIRouter, Depends, Query, Request
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
@@ -6,13 +12,19 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.api.deps import client_ip, get_current_user
|
from app.api.deps import client_ip, get_current_user
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.exceptions import JobNotCancellableError, NotFoundError
|
from app.core.exceptions import JobNotCancellableError, NotFoundError, ValidationError
|
||||||
|
from app.models.customer import Customer
|
||||||
from app.models.server import Server
|
from app.models.server import Server
|
||||||
from app.models.update_job import JobStatus, UpdateJob, UpdateLog
|
from app.models.update_job import JobStatus, JobType, UpdateJob, UpdateLog
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.update import JobTriggerRequest, UpdateJobRead, UpdateLogRead
|
from app.schemas.update import (
|
||||||
|
BatchJobTriggerRequest,
|
||||||
|
BatchJobTriggerResponse,
|
||||||
|
JobTriggerRequest,
|
||||||
|
UpdateJobRead,
|
||||||
|
UpdateLogRead,
|
||||||
|
)
|
||||||
from app.services.audit import AuditService
|
from app.services.audit import AuditService
|
||||||
from app.services.job_runner import job_runner
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -24,39 +36,69 @@ async def trigger_update(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
) -> UpdateJob:
|
) -> UpdateJob:
|
||||||
server = await db.get(Server, payload.server_id)
|
job = await _create_job(db, payload, user.username)
|
||||||
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(
|
await AuditService(db).log(
|
||||||
username=user.username,
|
username=user.username,
|
||||||
action="update.trigger",
|
action="update.trigger",
|
||||||
target=server.name,
|
target=f"job:{job.id}",
|
||||||
details={"job_id": job.id, "type": payload.type.value},
|
customer_id=payload.customer_id,
|
||||||
|
details={"type": payload.type.value, "server_id": payload.server_id},
|
||||||
ip_address=client_ip(request),
|
ip_address=client_ip(request),
|
||||||
)
|
)
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
await job_runner.start(job.id)
|
|
||||||
return job
|
return job
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/trigger-batch", response_model=BatchJobTriggerResponse, status_code=201)
|
||||||
|
async def trigger_batch(
|
||||||
|
payload: BatchJobTriggerRequest,
|
||||||
|
request: Request,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
) -> BatchJobTriggerResponse:
|
||||||
|
"""Queue one job per server - the satellite works through them in order."""
|
||||||
|
stmt = select(Server).where(Server.customer_id == payload.customer_id)
|
||||||
|
if payload.server_ids:
|
||||||
|
stmt = stmt.where(Server.id.in_(payload.server_ids))
|
||||||
|
result = await db.execute(stmt.order_by(Server.name))
|
||||||
|
servers = list(result.scalars().all())
|
||||||
|
if not servers:
|
||||||
|
raise NotFoundError("Keine Server für diesen Kunden gefunden")
|
||||||
|
|
||||||
|
job_ids: list[int] = []
|
||||||
|
for server in servers:
|
||||||
|
job = await _create_job(
|
||||||
|
db,
|
||||||
|
JobTriggerRequest(
|
||||||
|
customer_id=payload.customer_id,
|
||||||
|
type=payload.type,
|
||||||
|
server_id=server.id,
|
||||||
|
reboot_if_required=payload.reboot_if_required,
|
||||||
|
),
|
||||||
|
user.username,
|
||||||
|
)
|
||||||
|
job_ids.append(job.id)
|
||||||
|
|
||||||
|
await AuditService(db).log(
|
||||||
|
username=user.username,
|
||||||
|
action="update.trigger_batch",
|
||||||
|
customer_id=payload.customer_id,
|
||||||
|
details={"type": payload.type.value, "count": len(job_ids)},
|
||||||
|
ip_address=client_ip(request),
|
||||||
|
)
|
||||||
|
return BatchJobTriggerResponse(created=len(job_ids), job_ids=job_ids)
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[UpdateJobRead])
|
@router.get("", response_model=list[UpdateJobRead])
|
||||||
async def list_jobs(
|
async def list_jobs(
|
||||||
|
customer_id: int | None = None,
|
||||||
status: JobStatus | None = None,
|
status: JobStatus | None = None,
|
||||||
limit: int = Query(default=50, le=200),
|
limit: int = Query(default=50, le=200),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
_user: User = Depends(get_current_user),
|
_user: User = Depends(get_current_user),
|
||||||
) -> list[UpdateJob]:
|
) -> list[UpdateJob]:
|
||||||
stmt = select(UpdateJob).order_by(UpdateJob.id.desc()).limit(limit)
|
stmt = select(UpdateJob).order_by(UpdateJob.id.desc()).limit(limit)
|
||||||
|
if customer_id is not None:
|
||||||
|
stmt = stmt.where(UpdateJob.customer_id == customer_id)
|
||||||
if status:
|
if status:
|
||||||
stmt = stmt.where(UpdateJob.status == status)
|
stmt = stmt.where(UpdateJob.status == status)
|
||||||
result = await db.execute(stmt)
|
result = await db.execute(stmt)
|
||||||
@@ -103,17 +145,17 @@ async def cancel_job(
|
|||||||
job = await db.get(UpdateJob, job_id)
|
job = await db.get(UpdateJob, job_id)
|
||||||
if not job:
|
if not job:
|
||||||
raise NotFoundError("Job nicht gefunden")
|
raise NotFoundError("Job nicht gefunden")
|
||||||
if job.status not in (JobStatus.PENDING, JobStatus.RUNNING):
|
# Only pending jobs can be cancelled centrally - a claimed/running job
|
||||||
|
# is already on the satellite and finishes there.
|
||||||
|
if job.status != JobStatus.PENDING:
|
||||||
raise JobNotCancellableError()
|
raise JobNotCancellableError()
|
||||||
|
|
||||||
cancelled = await job_runner.cancel(job_id)
|
job.status = JobStatus.CANCELLED
|
||||||
if not cancelled:
|
|
||||||
job.status = JobStatus.CANCELLED
|
|
||||||
|
|
||||||
await AuditService(db).log(
|
await AuditService(db).log(
|
||||||
username=user.username,
|
username=user.username,
|
||||||
action="update.cancel",
|
action="update.cancel",
|
||||||
target=f"job:{job_id}",
|
target=f"job:{job_id}",
|
||||||
|
customer_id=job.customer_id,
|
||||||
ip_address=client_ip(request),
|
ip_address=client_ip(request),
|
||||||
)
|
)
|
||||||
return job
|
return job
|
||||||
@@ -121,14 +163,55 @@ async def cancel_job(
|
|||||||
|
|
||||||
@router.get("/stats/summary")
|
@router.get("/stats/summary")
|
||||||
async def job_stats(
|
async def job_stats(
|
||||||
|
customer_id: int | None = None,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
_user: User = Depends(get_current_user),
|
_user: User = Depends(get_current_user),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
total = await db.scalar(select(func.count(UpdateJob.id)))
|
base = select(func.count(UpdateJob.id))
|
||||||
|
if customer_id is not None:
|
||||||
|
base = base.where(UpdateJob.customer_id == customer_id)
|
||||||
|
total = await db.scalar(base)
|
||||||
running = await db.scalar(
|
running = await db.scalar(
|
||||||
select(func.count(UpdateJob.id)).where(UpdateJob.status == JobStatus.RUNNING)
|
base.where(UpdateJob.status.in_([JobStatus.CLAIMED, JobStatus.RUNNING]))
|
||||||
)
|
)
|
||||||
failed = await db.scalar(
|
failed = await db.scalar(base.where(UpdateJob.status == JobStatus.FAILED))
|
||||||
select(func.count(UpdateJob.id)).where(UpdateJob.status == JobStatus.FAILED)
|
pending = await db.scalar(base.where(UpdateJob.status == JobStatus.PENDING))
|
||||||
|
return {
|
||||||
|
"total": total or 0,
|
||||||
|
"running": running or 0,
|
||||||
|
"failed": failed or 0,
|
||||||
|
"pending": pending or 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _create_job(
|
||||||
|
db: AsyncSession, payload: JobTriggerRequest, username: str
|
||||||
|
) -> UpdateJob:
|
||||||
|
customer = await db.get(Customer, payload.customer_id)
|
||||||
|
if not customer:
|
||||||
|
raise NotFoundError("Kunde nicht gefunden")
|
||||||
|
|
||||||
|
if payload.type == JobType.NETWORK_SCAN:
|
||||||
|
if payload.server_id is not None:
|
||||||
|
raise ValidationError("NETWORK_SCAN hat keinen Ziel-Server")
|
||||||
|
else:
|
||||||
|
if payload.server_id is None:
|
||||||
|
raise ValidationError("server_id erforderlich")
|
||||||
|
server = await db.get(Server, payload.server_id)
|
||||||
|
if not server or server.customer_id != payload.customer_id:
|
||||||
|
raise NotFoundError("Server nicht gefunden")
|
||||||
|
|
||||||
|
params: dict = {"reboot_if_required": payload.reboot_if_required}
|
||||||
|
if payload.scan_subnet:
|
||||||
|
params["scan_subnet"] = payload.scan_subnet
|
||||||
|
|
||||||
|
job = UpdateJob(
|
||||||
|
customer_id=payload.customer_id,
|
||||||
|
server_id=payload.server_id,
|
||||||
|
type=payload.type,
|
||||||
|
created_by=username,
|
||||||
|
params=json.dumps(params),
|
||||||
)
|
)
|
||||||
return {"total": total or 0, "running": running or 0, "failed": failed or 0}
|
db.add(job)
|
||||||
|
await db.flush()
|
||||||
|
return job
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ class Settings(BaseSettings):
|
|||||||
app_env: str = "development"
|
app_env: str = "development"
|
||||||
app_name: str = "Insight Updater"
|
app_name: str = "Insight Updater"
|
||||||
secret_key: str = "dev-secret-change-me-32-chars-min"
|
secret_key: str = "dev-secret-change-me-32-chars-min"
|
||||||
encryption_key: str = ""
|
|
||||||
log_level: str = "INFO"
|
log_level: str = "INFO"
|
||||||
log_format: str = "json"
|
log_format: str = "json"
|
||||||
|
|
||||||
@@ -29,19 +28,12 @@ class Settings(BaseSettings):
|
|||||||
jwt_access_token_expire_minutes: int = 30
|
jwt_access_token_expire_minutes: int = 30
|
||||||
jwt_refresh_token_expire_days: int = 7
|
jwt_refresh_token_expire_days: int = 7
|
||||||
|
|
||||||
# Database / Redis
|
# Database
|
||||||
database_url: str = "sqlite+aiosqlite:///./data/app.db"
|
database_url: str = "sqlite+aiosqlite:///./data/app.db"
|
||||||
redis_url: str = "redis://localhost:6379/0"
|
|
||||||
|
|
||||||
# WinRM
|
# Jobs: a claimed/running job without satellite reports for this many
|
||||||
winrm_transport: str = "ntlm"
|
# seconds is considered stale and marked failed by the janitor
|
||||||
winrm_cert_validation: str = "ignore"
|
job_stale_timeout: int = 3600
|
||||||
winrm_operation_timeout: int = 60
|
|
||||||
winrm_read_timeout: int = 120
|
|
||||||
winrm_kerberos_delegation: bool = True
|
|
||||||
|
|
||||||
# SSH
|
|
||||||
ssh_timeout: int = 30
|
|
||||||
|
|
||||||
# LDAP (stub)
|
# LDAP (stub)
|
||||||
ldap_enabled: bool = False
|
ldap_enabled: bool = False
|
||||||
|
|||||||
@@ -62,5 +62,10 @@ class CAUError(AppError):
|
|||||||
detail = "Cluster-Aware Updating operation failed"
|
detail = "Cluster-Aware Updating operation failed"
|
||||||
|
|
||||||
|
|
||||||
|
class ValidationError(AppError):
|
||||||
|
status_code = 422
|
||||||
|
detail = "Validation failed"
|
||||||
|
|
||||||
|
|
||||||
class JobNotCancellableError(ConflictError):
|
class JobNotCancellableError(ConflictError):
|
||||||
detail = "Job cannot be cancelled in its current state"
|
detail = "Job cannot be cancelled in its current state"
|
||||||
|
|||||||
@@ -1,58 +1,21 @@
|
|||||||
"""Security helpers: Fernet credential encryption, JWT issue/verify, password hashing."""
|
"""Security helpers: JWT issue/verify, password hashing.
|
||||||
|
|
||||||
|
No credential encryption here - target-system credentials live exclusively
|
||||||
|
on the satellites, never in the central database.
|
||||||
|
"""
|
||||||
|
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import bcrypt
|
import bcrypt
|
||||||
from cryptography.fernet import Fernet, InvalidToken
|
|
||||||
from jose import JWTError, jwt
|
from jose import JWTError, jwt
|
||||||
|
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
from app.core.exceptions import CredentialDecryptionError, InvalidTokenError
|
from app.core.exceptions import InvalidTokenError
|
||||||
|
|
||||||
settings = get_settings()
|
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:
|
def hash_password(password: str) -> str:
|
||||||
# bcrypt hard limit: 72 bytes
|
# bcrypt hard limit: 72 bytes
|
||||||
@@ -66,11 +29,6 @@ def verify_password(plain: str, hashed: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# JWT (RS256 with key files, HS256 fallback for dev without keys)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _read_key(path: str) -> str | None:
|
def _read_key(path: str) -> str | None:
|
||||||
p = Path(path)
|
p = Path(path)
|
||||||
return p.read_text() if p.exists() else None
|
return p.read_text() if p.exists() else None
|
||||||
|
|||||||
+15
-22
@@ -1,14 +1,13 @@
|
|||||||
"""FastAPI application entrypoint.
|
"""FastAPI application entrypoint.
|
||||||
|
|
||||||
Mounts:
|
Central instance of the Insight Updater hub:
|
||||||
- REST API under /api
|
- Dashboard REST API under /api (JWT auth)
|
||||||
- Socket.io under /socket.io (path) -> frontend connects to ws://host/socket.io
|
- Satellite agent API under /api/satellite (X-Api-Key auth)
|
||||||
- /health liveness probe
|
- /health liveness probe
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
import socketio
|
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
@@ -18,7 +17,6 @@ from app.core.config import get_settings
|
|||||||
from app.core.database import init_db
|
from app.core.database import init_db
|
||||||
from app.core.exceptions import AppError
|
from app.core.exceptions import AppError
|
||||||
from app.core.logging import get_logger, setup_logging
|
from app.core.logging import get_logger, setup_logging
|
||||||
from app.websocket import sio
|
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
@@ -32,17 +30,15 @@ async def lifespan(app: FastAPI): # type: ignore[no-untyped-def]
|
|||||||
await init_db()
|
await init_db()
|
||||||
await _seed_default_admin()
|
await _seed_default_admin()
|
||||||
|
|
||||||
# Attach Redis manager for Socket.io pub/sub (optional in dev)
|
import asyncio
|
||||||
try:
|
|
||||||
from socketio import AsyncRedisManager
|
|
||||||
|
|
||||||
sio.manager = AsyncRedisManager(settings.redis_url)
|
from app.services.janitor import run_janitor
|
||||||
logger.info("ws.redis_manager_attached", url=settings.redis_url)
|
|
||||||
except Exception as exc: # noqa: BLE001 - Redis optional for scaffold
|
janitor = asyncio.create_task(run_janitor(), name="job-janitor")
|
||||||
logger.warning("ws.redis_unavailable", error=str(exc))
|
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
janitor.cancel()
|
||||||
logger.info("app.stopping")
|
logger.info("app.stopping")
|
||||||
|
|
||||||
|
|
||||||
@@ -77,13 +73,13 @@ async def _seed_default_admin() -> None:
|
|||||||
logger.info("app.default_admin_created", username="admin")
|
logger.info("app.default_admin_created", username="admin")
|
||||||
|
|
||||||
|
|
||||||
fastapi_app = FastAPI(
|
app = FastAPI(
|
||||||
title=settings.app_name,
|
title=settings.app_name,
|
||||||
version="0.1.0",
|
version="0.2.0",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
fastapi_app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=settings.cors_origin_list,
|
allow_origins=settings.cors_origin_list,
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
@@ -92,17 +88,14 @@ fastapi_app.add_middleware(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@fastapi_app.exception_handler(AppError)
|
@app.exception_handler(AppError)
|
||||||
async def app_error_handler(request: Request, exc: AppError) -> JSONResponse: # noqa: ARG001
|
async def app_error_handler(request: Request, exc: AppError) -> JSONResponse: # noqa: ARG001
|
||||||
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
|
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
|
||||||
|
|
||||||
|
|
||||||
@fastapi_app.get("/health")
|
@app.get("/health")
|
||||||
async def health() -> dict:
|
async def health() -> dict:
|
||||||
return {"status": "ok", "env": settings.app_env, "version": "0.1.0"}
|
return {"status": "ok", "env": settings.app_env, "version": "0.2.0"}
|
||||||
|
|
||||||
|
|
||||||
fastapi_app.include_router(api_router)
|
app.include_router(api_router)
|
||||||
|
|
||||||
# Combined ASGI app: FastAPI + Socket.io
|
|
||||||
app = socketio.ASGIApp(sio, other_asgi_app=fastapi_app, socketio_path="socket.io")
|
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
"""SQLAlchemy ORM models."""
|
"""SQLAlchemy ORM models."""
|
||||||
|
|
||||||
from app.models.audit_log import AuditLog
|
from app.models.audit_log import AuditLog
|
||||||
from app.models.credential import Credential
|
from app.models.customer import Customer
|
||||||
|
from app.models.satellite import Satellite
|
||||||
from app.models.server import Server
|
from app.models.server import Server
|
||||||
from app.models.update_job import UpdateJob, UpdateLog
|
from app.models.update_job import UpdateJob, UpdateLog
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"AuditLog",
|
"AuditLog",
|
||||||
"Credential",
|
"Customer",
|
||||||
|
"Satellite",
|
||||||
"Server",
|
"Server",
|
||||||
"UpdateJob",
|
"UpdateJob",
|
||||||
"UpdateLog",
|
"UpdateLog",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from sqlalchemy import DateTime, String, Text
|
from sqlalchemy import DateTime, ForeignKey, String, Text
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from app.core.database import Base
|
from app.core.database import Base
|
||||||
@@ -15,9 +15,12 @@ class AuditLog(Base):
|
|||||||
timestamp: Mapped[datetime] = mapped_column(
|
timestamp: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), default=lambda: datetime.now(UTC), index=True
|
DateTime(timezone=True), default=lambda: datetime.now(UTC), index=True
|
||||||
)
|
)
|
||||||
|
customer_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("customers.id"), nullable=True, index=True
|
||||||
|
)
|
||||||
username: Mapped[str] = mapped_column(String(255), index=True)
|
username: Mapped[str] = mapped_column(String(255), index=True)
|
||||||
action: Mapped[str] = mapped_column(String(100), index=True) # e.g. server.create
|
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
|
target: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
result: Mapped[str] = mapped_column(String(50), default="success") # success | failure
|
result: Mapped[str] = mapped_column(String(50), default="success") # success | failure
|
||||||
details: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON blob
|
details: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON blob
|
||||||
ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True)
|
ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True)
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
"""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,36 @@
|
|||||||
|
"""Customer model - one per client site (tenant)."""
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, String, Text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Customer(Base):
|
||||||
|
__tablename__ = "customers"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
||||||
|
slug: Mapped[str] = mapped_column(String(100), unique=True, index=True)
|
||||||
|
notes: 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),
|
||||||
|
)
|
||||||
|
|
||||||
|
satellites: Mapped[list["Satellite"]] = relationship( # noqa: F821
|
||||||
|
back_populates="customer", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
servers: Mapped[list["Server"]] = relationship( # noqa: F821
|
||||||
|
back_populates="customer", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
jobs: Mapped[list["UpdateJob"]] = relationship( # noqa: F821
|
||||||
|
back_populates="customer", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""Satellite model - remote agent at a customer site.
|
||||||
|
|
||||||
|
The API key is stored as a SHA-256 hash; the plaintext key is shown
|
||||||
|
exactly once at creation time.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, ForeignKey, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
def generate_api_key() -> str:
|
||||||
|
"""Generate a new satellite API key (plaintext, show once)."""
|
||||||
|
return f"ius_{secrets.token_urlsafe(32)}"
|
||||||
|
|
||||||
|
|
||||||
|
def hash_api_key(key: str) -> str:
|
||||||
|
return hashlib.sha256(key.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
class Satellite(Base):
|
||||||
|
__tablename__ = "satellites"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
customer_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("customers.id"), index=True
|
||||||
|
)
|
||||||
|
customer: Mapped["Customer"] = relationship(back_populates="satellites") # noqa: F821
|
||||||
|
|
||||||
|
name: Mapped[str] = mapped_column(String(255))
|
||||||
|
api_key_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||||
|
api_key_prefix: Mapped[str] = mapped_column(String(12)) # for display in UI
|
||||||
|
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
|
||||||
|
# Filled by heartbeat
|
||||||
|
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
version: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
hostname: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), default=lambda: datetime.now(UTC)
|
||||||
|
)
|
||||||
|
|
||||||
|
jobs: Mapped[list["UpdateJob"]] = relationship( # noqa: F821
|
||||||
|
back_populates="satellite"
|
||||||
|
)
|
||||||
@@ -1,15 +1,19 @@
|
|||||||
"""Server inventory model."""
|
"""Server inventory model.
|
||||||
|
|
||||||
|
Credentials are NOT stored centrally. `credential_ref` is a symbolic name
|
||||||
|
that the satellite resolves against its local credentials.yaml.
|
||||||
|
"""
|
||||||
|
|
||||||
import enum
|
import enum
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from sqlalchemy import DateTime, Enum, ForeignKey, String, Text
|
from sqlalchemy import DateTime, Enum, ForeignKey, String, Text, UniqueConstraint
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.core.database import Base
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
class ServerType(str, enum.Enum):
|
class ServerType(enum.StrEnum):
|
||||||
WINDOWS = "windows" # WinRM
|
WINDOWS = "windows" # WinRM
|
||||||
LINUX = "linux" # SSH
|
LINUX = "linux" # SSH
|
||||||
CAU_CLUSTER = "cau_cluster" # Cluster-Aware Updating
|
CAU_CLUSTER = "cau_cluster" # Cluster-Aware Updating
|
||||||
@@ -17,22 +21,33 @@ class ServerType(str, enum.Enum):
|
|||||||
|
|
||||||
class Server(Base):
|
class Server(Base):
|
||||||
__tablename__ = "servers"
|
__tablename__ = "servers"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("customer_id", "name", name="uq_server_customer_name"),
|
||||||
|
)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
name: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
customer_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("customers.id"), index=True
|
||||||
|
)
|
||||||
|
customer: Mapped["Customer"] = relationship(back_populates="servers") # noqa: F821
|
||||||
|
|
||||||
|
name: Mapped[str] = mapped_column(String(255), index=True)
|
||||||
hostname: Mapped[str] = mapped_column(String(255))
|
hostname: Mapped[str] = mapped_column(String(255))
|
||||||
port: Mapped[int] = mapped_column(default=5985)
|
port: Mapped[int] = mapped_column(default=5985)
|
||||||
type: Mapped[ServerType] = mapped_column(Enum(ServerType), default=ServerType.WINDOWS)
|
type: Mapped[ServerType] = mapped_column(Enum(ServerType), default=ServerType.WINDOWS)
|
||||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
tags: Mapped[str | None] = mapped_column(String(500), nullable=True) # comma-separated
|
tags: Mapped[str | None] = mapped_column(String(500), nullable=True) # comma-separated
|
||||||
|
|
||||||
credential_id: Mapped[int | None] = mapped_column(
|
# Symbolic reference to a credential stored locally on the satellite
|
||||||
ForeignKey("credentials.id"), nullable=True
|
credential_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
)
|
|
||||||
credential: Mapped["Credential | None"] = relationship(lazy="selectin") # noqa: F821
|
|
||||||
|
|
||||||
|
# Last health result reported by a satellite
|
||||||
last_health_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
last_health_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
last_health_ok: Mapped[bool | None] = mapped_column(nullable=True)
|
last_health_ok: Mapped[bool | None] = mapped_column(nullable=True)
|
||||||
|
last_health_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
# Set by network scan jobs
|
||||||
|
discovered_by_scan: Mapped[bool] = mapped_column(default=False)
|
||||||
|
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), default=lambda: datetime.now(UTC)
|
DateTime(timezone=True), default=lambda: datetime.now(UTC)
|
||||||
@@ -44,5 +59,5 @@ class Server(Base):
|
|||||||
)
|
)
|
||||||
|
|
||||||
jobs: Mapped[list["UpdateJob"]] = relationship( # noqa: F821
|
jobs: Mapped[list["UpdateJob"]] = relationship( # noqa: F821
|
||||||
back_populates="server", cascade="all, delete-orphan"
|
back_populates="server"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
"""Update job + streamed log line models."""
|
"""Update job + log line models.
|
||||||
|
|
||||||
|
Job lifecycle (pull model):
|
||||||
|
pending -> claimed (satellite picked it up) -> running -> success | failed | cancelled
|
||||||
|
A claimed/running job whose satellite goes silent past the stale timeout
|
||||||
|
is marked failed by the janitor.
|
||||||
|
"""
|
||||||
|
|
||||||
import enum
|
import enum
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
@@ -9,36 +15,58 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|||||||
from app.core.database import Base
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
class JobStatus(str, enum.Enum):
|
class JobStatus(enum.StrEnum):
|
||||||
PENDING = "pending"
|
PENDING = "pending"
|
||||||
|
CLAIMED = "claimed"
|
||||||
RUNNING = "running"
|
RUNNING = "running"
|
||||||
SUCCESS = "success"
|
SUCCESS = "success"
|
||||||
FAILED = "failed"
|
FAILED = "failed"
|
||||||
CANCELLED = "cancelled"
|
CANCELLED = "cancelled"
|
||||||
|
|
||||||
|
|
||||||
class JobType(str, enum.Enum):
|
class JobType(enum.StrEnum):
|
||||||
WINDOWS_UPDATE = "windows_update"
|
WINDOWS_UPDATE = "windows_update"
|
||||||
LINUX_UPDATE = "linux_update"
|
LINUX_UPDATE = "linux_update"
|
||||||
CAU_RUN = "cau_run"
|
CAU_RUN = "cau_run"
|
||||||
HEALTH_CHECK = "health_check"
|
HEALTH_CHECK = "health_check"
|
||||||
|
NETWORK_SCAN = "network_scan"
|
||||||
|
|
||||||
|
|
||||||
class UpdateJob(Base):
|
class UpdateJob(Base):
|
||||||
__tablename__ = "update_jobs"
|
__tablename__ = "update_jobs"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
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
|
customer_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("customers.id"), index=True
|
||||||
|
)
|
||||||
|
customer: Mapped["Customer"] = relationship(back_populates="jobs", lazy="selectin") # noqa: F821
|
||||||
|
|
||||||
|
# Null for NETWORK_SCAN jobs (target = whole local network)
|
||||||
|
server_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("servers.id"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
server: Mapped["Server | None"] = relationship(back_populates="jobs", lazy="selectin") # noqa: F821
|
||||||
|
|
||||||
|
# Set when a satellite claims the job
|
||||||
|
satellite_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("satellites.id"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
satellite: Mapped["Satellite | None"] = relationship(back_populates="jobs", lazy="selectin") # noqa: F821
|
||||||
|
|
||||||
type: Mapped[JobType] = mapped_column(Enum(JobType))
|
type: Mapped[JobType] = mapped_column(Enum(JobType))
|
||||||
status: Mapped[JobStatus] = mapped_column(Enum(JobStatus), default=JobStatus.PENDING, index=True)
|
status: Mapped[JobStatus] = mapped_column(Enum(JobStatus), default=JobStatus.PENDING, index=True)
|
||||||
progress_percent: Mapped[int] = mapped_column(Integer, default=0)
|
progress_percent: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
current_phase: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
current_phase: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
|
||||||
started_by: Mapped[str] = mapped_column(String(255)) # username
|
# Optional job parameters (e.g. reboot_if_required, scan_subnet)
|
||||||
|
params: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON blob
|
||||||
|
|
||||||
|
created_by: Mapped[str] = mapped_column(String(255)) # dashboard username
|
||||||
|
claimed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
last_report_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
|||||||
@@ -1,18 +1 @@
|
|||||||
"""Pydantic schemas (request/response)."""
|
"""Pydantic request/response schemas."""
|
||||||
|
|
||||||
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",
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ class AuditLogRead(BaseModel):
|
|||||||
|
|
||||||
id: int
|
id: int
|
||||||
timestamp: datetime
|
timestamp: datetime
|
||||||
|
customer_id: int | None
|
||||||
username: str
|
username: str
|
||||||
action: str
|
action: str
|
||||||
target: str | None
|
target: str | None
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""Customer schemas."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerCreate(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=255)
|
||||||
|
slug: str = Field(min_length=1, max_length=100, pattern=r"^[a-z0-9-]+$")
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerUpdate(BaseModel):
|
||||||
|
name: str | None = None
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
slug: str
|
||||||
|
notes: str | None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""Satellite schemas."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class SatelliteCreate(BaseModel):
|
||||||
|
customer_id: int
|
||||||
|
name: str = Field(min_length=1, max_length=255)
|
||||||
|
|
||||||
|
|
||||||
|
class SatelliteRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
customer_id: int
|
||||||
|
name: str
|
||||||
|
api_key_prefix: str
|
||||||
|
is_active: bool
|
||||||
|
last_seen_at: datetime | None
|
||||||
|
version: str | None
|
||||||
|
hostname: str | None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class SatelliteCreated(SatelliteRead):
|
||||||
|
"""Returned exactly once on creation - contains the plaintext API key."""
|
||||||
|
|
||||||
|
api_key: str
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""Satellite-facing schemas (agent API)."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.models.update_job import JobType
|
||||||
|
|
||||||
|
|
||||||
|
class HeartbeatRequest(BaseModel):
|
||||||
|
version: str = Field(max_length=50)
|
||||||
|
hostname: str = Field(max_length=255)
|
||||||
|
|
||||||
|
|
||||||
|
class PollResponse(BaseModel):
|
||||||
|
"""Jobs handed to the satellite on poll. Empty list = nothing to do."""
|
||||||
|
|
||||||
|
jobs: list["SatelliteJob"]
|
||||||
|
|
||||||
|
|
||||||
|
class SatelliteJob(BaseModel):
|
||||||
|
job_id: int
|
||||||
|
type: JobType
|
||||||
|
# Target info (absent for network scans)
|
||||||
|
server_id: int | None = None
|
||||||
|
server_name: str | None = None
|
||||||
|
hostname: str | None = None
|
||||||
|
port: int | None = None
|
||||||
|
server_type: str | None = None
|
||||||
|
credential_ref: str | None = None
|
||||||
|
# Parameters
|
||||||
|
reboot_if_required: bool = False
|
||||||
|
scan_subnet: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class LogLine(BaseModel):
|
||||||
|
timestamp: datetime
|
||||||
|
level: str = "info"
|
||||||
|
line: str
|
||||||
|
|
||||||
|
|
||||||
|
class LogBatchRequest(BaseModel):
|
||||||
|
job_id: int
|
||||||
|
lines: list[LogLine]
|
||||||
|
progress_percent: int | None = None
|
||||||
|
current_phase: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class JobResultRequest(BaseModel):
|
||||||
|
job_id: int
|
||||||
|
status: str # "success" | "failed"
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ScanResultHost(BaseModel):
|
||||||
|
hostname: str
|
||||||
|
ip: str
|
||||||
|
os_guess: str | None = None # "windows" | "linux" | None
|
||||||
|
winrm_open: bool = False
|
||||||
|
ssh_open: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class ScanResultRequest(BaseModel):
|
||||||
|
job_id: int
|
||||||
|
hosts: list[ScanResultHost]
|
||||||
|
|
||||||
|
|
||||||
|
class HealthReportRequest(BaseModel):
|
||||||
|
server_id: int
|
||||||
|
ok: bool
|
||||||
|
message: str
|
||||||
@@ -8,13 +8,14 @@ from app.models.server import ServerType
|
|||||||
|
|
||||||
|
|
||||||
class ServerCreate(BaseModel):
|
class ServerCreate(BaseModel):
|
||||||
|
customer_id: int
|
||||||
name: str = Field(min_length=1, max_length=255)
|
name: str = Field(min_length=1, max_length=255)
|
||||||
hostname: str = Field(min_length=1, max_length=255)
|
hostname: str = Field(min_length=1, max_length=255)
|
||||||
port: int = 5985
|
port: int = 5985
|
||||||
type: ServerType = ServerType.WINDOWS
|
type: ServerType = ServerType.WINDOWS
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
tags: str | None = None
|
tags: str | None = None
|
||||||
credential_id: int | None = None
|
credential_ref: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class ServerUpdate(BaseModel):
|
class ServerUpdate(BaseModel):
|
||||||
@@ -24,29 +25,24 @@ class ServerUpdate(BaseModel):
|
|||||||
type: ServerType | None = None
|
type: ServerType | None = None
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
tags: str | None = None
|
tags: str | None = None
|
||||||
credential_id: int | None = None
|
credential_ref: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class ServerRead(BaseModel):
|
class ServerRead(BaseModel):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
id: int
|
id: int
|
||||||
|
customer_id: int
|
||||||
name: str
|
name: str
|
||||||
hostname: str
|
hostname: str
|
||||||
port: int
|
port: int
|
||||||
type: ServerType
|
type: ServerType
|
||||||
description: str | None
|
description: str | None
|
||||||
tags: str | None
|
tags: str | None
|
||||||
credential_id: int | None
|
credential_ref: str | None
|
||||||
last_health_at: datetime | None
|
last_health_at: datetime | None
|
||||||
last_health_ok: bool | None
|
last_health_ok: bool | None
|
||||||
|
last_health_message: str | None
|
||||||
|
discovered_by_scan: bool
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
class HealthCheckResult(BaseModel):
|
|
||||||
server_id: int
|
|
||||||
ok: bool
|
|
||||||
latency_ms: float | None = None
|
|
||||||
message: str
|
|
||||||
checked_at: datetime
|
|
||||||
|
|||||||
@@ -8,24 +8,28 @@ from app.models.update_job import JobStatus, JobType
|
|||||||
|
|
||||||
|
|
||||||
class JobTriggerRequest(BaseModel):
|
class JobTriggerRequest(BaseModel):
|
||||||
server_id: int
|
customer_id: int
|
||||||
type: JobType
|
type: JobType
|
||||||
# CAU-specific options
|
# Target server; not required for NETWORK_SCAN
|
||||||
cluster_name: str | None = None
|
server_id: int | None = None
|
||||||
# Linux-specific options
|
# Optional parameters
|
||||||
reboot_if_required: bool = False
|
reboot_if_required: bool = False
|
||||||
|
scan_subnet: str | None = None # e.g. "192.168.1.0/24"
|
||||||
|
|
||||||
|
|
||||||
class UpdateJobRead(BaseModel):
|
class UpdateJobRead(BaseModel):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
id: int
|
id: int
|
||||||
server_id: int
|
customer_id: int
|
||||||
|
server_id: int | None
|
||||||
|
satellite_id: int | None
|
||||||
type: JobType
|
type: JobType
|
||||||
status: JobStatus
|
status: JobStatus
|
||||||
progress_percent: int
|
progress_percent: int
|
||||||
current_phase: str | None
|
current_phase: str | None
|
||||||
started_by: str
|
created_by: str
|
||||||
|
claimed_at: datetime | None
|
||||||
started_at: datetime | None
|
started_at: datetime | None
|
||||||
finished_at: datetime | None
|
finished_at: datetime | None
|
||||||
error: str | None
|
error: str | None
|
||||||
@@ -40,3 +44,17 @@ class UpdateLogRead(BaseModel):
|
|||||||
timestamp: datetime
|
timestamp: datetime
|
||||||
level: str
|
level: str
|
||||||
line: str
|
line: str
|
||||||
|
|
||||||
|
|
||||||
|
class BatchJobTriggerRequest(BaseModel):
|
||||||
|
"""Trigger one job per server (or all servers of a customer)."""
|
||||||
|
|
||||||
|
customer_id: int
|
||||||
|
type: JobType
|
||||||
|
server_ids: list[int] | None = None # None = all servers of the customer
|
||||||
|
reboot_if_required: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class BatchJobTriggerResponse(BaseModel):
|
||||||
|
created: int
|
||||||
|
job_ids: list[int]
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ class AuditService:
|
|||||||
result: str = "success",
|
result: str = "success",
|
||||||
details: dict[str, Any] | None = None,
|
details: dict[str, Any] | None = None,
|
||||||
ip_address: str | None = None,
|
ip_address: str | None = None,
|
||||||
|
customer_id: int | None = None,
|
||||||
) -> AuditLog:
|
) -> AuditLog:
|
||||||
entry = AuditLog(
|
entry = AuditLog(
|
||||||
username=username,
|
username=username,
|
||||||
@@ -33,6 +34,7 @@ class AuditService:
|
|||||||
result=result,
|
result=result,
|
||||||
details=json.dumps(details) if details else None,
|
details=json.dumps(details) if details else None,
|
||||||
ip_address=ip_address,
|
ip_address=ip_address,
|
||||||
|
customer_id=customer_id,
|
||||||
)
|
)
|
||||||
self.db.add(entry)
|
self.db.add(entry)
|
||||||
await self.db.flush()
|
await self.db.flush()
|
||||||
@@ -42,5 +44,6 @@ class AuditService:
|
|||||||
action=action,
|
action=action,
|
||||||
target=target,
|
target=target,
|
||||||
result=result,
|
result=result,
|
||||||
|
customer_id=customer_id,
|
||||||
)
|
)
|
||||||
return entry
|
return entry
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
"""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,47 @@
|
|||||||
|
"""Stale job janitor: marks claimed/running jobs as failed when their
|
||||||
|
satellite stops reporting (e.g. satellite offline, job crashed).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.core.database import async_session_factory
|
||||||
|
from app.core.logging import get_logger
|
||||||
|
from app.models.update_job import JobStatus, UpdateJob
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
CHECK_INTERVAL = 60 # seconds
|
||||||
|
|
||||||
|
|
||||||
|
async def run_janitor() -> None:
|
||||||
|
"""Background task; runs until cancelled."""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await _sweep()
|
||||||
|
except Exception as exc: # noqa: BLE001 - janitor must never die
|
||||||
|
logger.error("janitor.error", error=str(exc))
|
||||||
|
await asyncio.sleep(CHECK_INTERVAL)
|
||||||
|
|
||||||
|
|
||||||
|
async def _sweep() -> None:
|
||||||
|
cutoff = datetime.now(UTC) - timedelta(seconds=settings.job_stale_timeout)
|
||||||
|
async with async_session_factory() as db:
|
||||||
|
result = await db.execute(
|
||||||
|
select(UpdateJob).where(
|
||||||
|
UpdateJob.status.in_([JobStatus.CLAIMED, JobStatus.RUNNING]),
|
||||||
|
UpdateJob.last_report_at < cutoff,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
stale = list(result.scalars().all())
|
||||||
|
for job in stale:
|
||||||
|
job.status = JobStatus.FAILED
|
||||||
|
job.error = "Satellite meldet sich nicht mehr (Timeout)"
|
||||||
|
job.finished_at = datetime.now(UTC)
|
||||||
|
logger.warning("janitor.job_stale", job_id=job.id, satellite_id=job.satellite_id)
|
||||||
|
if stale:
|
||||||
|
await db.commit()
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
"""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()
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
"""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)
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
"""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"]
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
"""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),
|
|
||||||
)
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
"""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()
|
|
||||||
@@ -11,16 +11,11 @@ dependencies = [
|
|||||||
"alembic>=1.13.0",
|
"alembic>=1.13.0",
|
||||||
"aiosqlite>=0.19.0",
|
"aiosqlite>=0.19.0",
|
||||||
"asyncpg>=0.29.0",
|
"asyncpg>=0.29.0",
|
||||||
"redis>=5.0.0",
|
|
||||||
"python-jose[cryptography]>=3.3.0",
|
"python-jose[cryptography]>=3.3.0",
|
||||||
"bcrypt>=4.1.0",
|
"bcrypt>=4.1.0",
|
||||||
"cryptography>=42.0.0",
|
"cryptography>=42.0.0",
|
||||||
"pydantic[email]>=2.5.0",
|
"pydantic[email]>=2.5.0",
|
||||||
"pydantic-settings>=2.1.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",
|
"structlog>=24.1.0",
|
||||||
"python-json-logger>=2.0.7",
|
"python-json-logger>=2.0.7",
|
||||||
"python-multipart>=0.0.6",
|
"python-multipart>=0.0.6",
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
# Internal-only override: publish the frontend directly on the host.
|
||||||
|
# No Traefik server required; API/WS stay behind the frontend nginx proxy.
|
||||||
|
services:
|
||||||
|
frontend:
|
||||||
|
ports:
|
||||||
|
- "8080:80"
|
||||||
@@ -14,13 +14,9 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- APP_ENV=production
|
- APP_ENV=production
|
||||||
- DATABASE_URL=postgresql+asyncpg://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME}
|
- DATABASE_URL=postgresql+asyncpg://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME}
|
||||||
- REDIS_URL=redis://redis:6379/0
|
|
||||||
- SECRET_KEY=${SECRET_KEY}
|
- SECRET_KEY=${SECRET_KEY}
|
||||||
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
|
|
||||||
- JWT_PRIVATE_KEY_PATH=/app/keys/private.pem
|
- JWT_PRIVATE_KEY_PATH=/app/keys/private.pem
|
||||||
- JWT_PUBLIC_KEY_PATH=/app/keys/public.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_ENABLED=${LDAP_ENABLED:-false}
|
||||||
- LDAP_URI=${LDAP_URI}
|
- LDAP_URI=${LDAP_URI}
|
||||||
- LDAP_BIND_DN=${LDAP_BIND_DN}
|
- LDAP_BIND_DN=${LDAP_BIND_DN}
|
||||||
@@ -57,10 +53,6 @@ services:
|
|||||||
- "traefik.http.routers.updater-api.entrypoints=websecure"
|
- "traefik.http.routers.updater-api.entrypoints=websecure"
|
||||||
- "traefik.http.routers.updater-api.tls.certresolver=letsencrypt"
|
- "traefik.http.routers.updater-api.tls.certresolver=letsencrypt"
|
||||||
- "traefik.http.services.updater-api.loadbalancer.server.port=8000"
|
- "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 - Nginx (Production)
|
||||||
@@ -83,24 +75,6 @@ services:
|
|||||||
- "traefik.http.routers.updater-web.tls.certresolver=letsencrypt"
|
- "traefik.http.routers.updater-web.tls.certresolver=letsencrypt"
|
||||||
- "traefik.http.services.updater-web.loadbalancer.server.port=80"
|
- "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
|
# PostgreSQL - Production Database
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
@@ -134,5 +108,4 @@ networks:
|
|||||||
external: true
|
external: true
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
redis-data:
|
|
||||||
pg-data:
|
pg-data:
|
||||||
+5
-48
@@ -1,9 +1,9 @@
|
|||||||
version: '3.8'
|
version: '3.8'
|
||||||
|
|
||||||
|
# Zentrale Insight-Updater-Instanz (Hub). Satelliten laufen beim Kunden,
|
||||||
|
# nicht hier - siehe satellite/.
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# BACKEND - FastAPI
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
backend:
|
backend:
|
||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
@@ -13,14 +13,10 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- APP_ENV=development
|
- APP_ENV=development
|
||||||
- DATABASE_URL=sqlite+aiosqlite:///./data/app.db
|
- DATABASE_URL=sqlite+aiosqlite:///./data/app.db
|
||||||
- REDIS_URL=redis://redis:6379/0
|
|
||||||
- SECRET_KEY=${SECRET_KEY:-dev-secret-change-me-32-chars-min}
|
- 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_ALGORITHM=RS256
|
||||||
- JWT_PRIVATE_KEY_PATH=/app/keys/private.pem
|
- JWT_PRIVATE_KEY_PATH=/app/keys/private.pem
|
||||||
- JWT_PUBLIC_KEY_PATH=/app/keys/public.pem
|
- JWT_PUBLIC_KEY_PATH=/app/keys/public.pem
|
||||||
- WINRM_TRANSPORT=ntlm
|
|
||||||
- WINRM_CERT_VALIDATION=ignore
|
|
||||||
- LDAP_ENABLED=false
|
- LDAP_ENABLED=false
|
||||||
- LOG_LEVEL=DEBUG
|
- LOG_LEVEL=DEBUG
|
||||||
volumes:
|
volumes:
|
||||||
@@ -29,9 +25,6 @@ services:
|
|||||||
- ./backend/keys:/app/keys:ro
|
- ./backend/keys:/app/keys:ro
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
depends_on:
|
|
||||||
redis:
|
|
||||||
condition: service_healthy
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
@@ -41,9 +34,6 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- insight-updater-network
|
- insight-updater-network
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# FRONTEND - Vue 3 + Vite (dev) / Nginx (prod)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
@@ -52,7 +42,6 @@ services:
|
|||||||
container_name: insight-updater-frontend
|
container_name: insight-updater-frontend
|
||||||
environment:
|
environment:
|
||||||
- VITE_API_URL=http://localhost:8000
|
- VITE_API_URL=http://localhost:8000
|
||||||
- VITE_WS_URL=ws://localhost:8000
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./frontend:/app
|
- ./frontend:/app
|
||||||
- /app/node_modules
|
- /app/node_modules
|
||||||
@@ -63,28 +52,7 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- insight-updater-network
|
- insight-updater-network
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# PostgreSQL (optional, fuer Produktion)
|
||||||
# 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:
|
# postgres:
|
||||||
# image: postgres:16-alpine
|
# image: postgres:16-alpine
|
||||||
# container_name: insight-updater-postgres
|
# container_name: insight-updater-postgres
|
||||||
@@ -94,20 +62,9 @@ services:
|
|||||||
# - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
# - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||||
# volumes:
|
# volumes:
|
||||||
# - postgres-data:/var/lib/postgresql/data
|
# - 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:
|
# networks:
|
||||||
# - insight-updater-network
|
# - insight-updater-network
|
||||||
|
|
||||||
volumes:
|
|
||||||
redis-data:
|
|
||||||
# postgres-data:
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
insight-updater-network:
|
insight-updater-network:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|||||||
Generated
+1
-65
@@ -15,7 +15,6 @@
|
|||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"lucide-vue-next": "^0.378.0",
|
"lucide-vue-next": "^0.378.0",
|
||||||
"pinia": "^2.1.0",
|
"pinia": "^2.1.0",
|
||||||
"socket.io-client": "^4.7.0",
|
|
||||||
"tailwind-merge": "^2.2.0",
|
"tailwind-merge": "^2.2.0",
|
||||||
"vue": "^3.4.0",
|
"vue": "^3.4.0",
|
||||||
"vue-router": "^4.3.0",
|
"vue-router": "^4.3.0",
|
||||||
@@ -1294,12 +1293,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@socket.io/component-emitter": {
|
|
||||||
"version": "3.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
|
||||||
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@tailwindcss/forms": {
|
"node_modules/@tailwindcss/forms": {
|
||||||
"version": "0.5.11",
|
"version": "0.5.11",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.11.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.11.tgz",
|
||||||
@@ -2741,28 +2734,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/engine.io-client": {
|
|
||||||
"version": "6.6.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.6.tgz",
|
|
||||||
"integrity": "sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@socket.io/component-emitter": "~3.1.0",
|
|
||||||
"debug": "~4.4.1",
|
|
||||||
"engine.io-parser": "~5.2.1",
|
|
||||||
"ws": "~8.21.0",
|
|
||||||
"xmlhttprequest-ssl": "~2.1.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/engine.io-parser": {
|
|
||||||
"version": "5.2.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
|
|
||||||
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/entities": {
|
"node_modules/entities": {
|
||||||
"version": "7.0.1",
|
"version": "7.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
||||||
@@ -5300,34 +5271,6 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/socket.io-client": {
|
|
||||||
"version": "4.8.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
|
|
||||||
"integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@socket.io/component-emitter": "~3.1.0",
|
|
||||||
"debug": "~4.4.1",
|
|
||||||
"engine.io-client": "~6.6.1",
|
|
||||||
"socket.io-parser": "~4.2.4"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/socket.io-parser": {
|
|
||||||
"version": "4.2.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.7.tgz",
|
|
||||||
"integrity": "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@socket.io/component-emitter": "~3.1.0",
|
|
||||||
"debug": "~4.4.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/source-map-js": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
@@ -6402,6 +6345,7 @@
|
|||||||
"version": "8.21.1",
|
"version": "8.21.1",
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
|
||||||
"integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
|
"integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10.0.0"
|
"node": ">=10.0.0"
|
||||||
@@ -6437,14 +6381,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/xmlhttprequest-ssl": {
|
|
||||||
"version": "2.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
|
|
||||||
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/yocto-queue": {
|
"node_modules/yocto-queue": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||||
|
|||||||
@@ -18,7 +18,6 @@
|
|||||||
"pinia": "^2.1.0",
|
"pinia": "^2.1.0",
|
||||||
"@vueuse/core": "^10.9.0",
|
"@vueuse/core": "^10.9.0",
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
"socket.io-client": "^4.7.0",
|
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"zod": "^3.22.0",
|
"zod": "^3.22.0",
|
||||||
"@tanstack/vue-query": "^5.0.0",
|
"@tanstack/vue-query": "^5.0.0",
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +1,31 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted } from 'vue'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { useCustomersStore } from '@/stores/customers'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const customersStore = useCustomersStore()
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ name: 'dashboard', label: 'Dashboard' },
|
{ name: 'dashboard', label: 'Dashboard' },
|
||||||
|
{ name: 'customers', label: 'Kunden' },
|
||||||
|
{ name: 'satellites', label: 'Satelliten' },
|
||||||
{ name: 'servers', label: 'Server' },
|
{ name: 'servers', label: 'Server' },
|
||||||
{ name: 'updates', label: 'Updates' },
|
{ name: 'updates', label: 'Updates' },
|
||||||
{ name: 'audit', label: 'Audit' },
|
{ name: 'audit', label: 'Audit' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const selected = computed({
|
||||||
|
get: () => customersStore.selectedId,
|
||||||
|
set: (id: number | null) => {
|
||||||
|
if (id !== null) customersStore.select(id)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => customersStore.fetchCustomers())
|
||||||
|
|
||||||
function logout(): void {
|
function logout(): void {
|
||||||
auth.logout()
|
auth.logout()
|
||||||
router.push({ name: 'login' })
|
router.push({ name: 'login' })
|
||||||
@@ -35,12 +49,24 @@ function logout(): void {
|
|||||||
{{ item.label }}
|
{{ item.label }}
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div class="flex items-center gap-3">
|
||||||
class="rounded bg-slate-700 px-3 py-1.5 text-sm hover:bg-slate-600"
|
<select
|
||||||
@click="logout"
|
v-model="selected"
|
||||||
>
|
class="rounded bg-slate-700 px-2 py-1.5 text-sm text-white"
|
||||||
Abmelden
|
title="Aktiver Kunde"
|
||||||
</button>
|
>
|
||||||
|
<option :value="null" disabled>Kunde wählen</option>
|
||||||
|
<option v-for="c in customersStore.customers" :key="c.id" :value="c.id">
|
||||||
|
{{ c.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
class="rounded bg-slate-700 px-3 py-1.5 text-sm hover:bg-slate-600"
|
||||||
|
@click="logout"
|
||||||
|
>
|
||||||
|
Abmelden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -14,6 +14,16 @@ const router = createRouter({
|
|||||||
name: 'dashboard',
|
name: 'dashboard',
|
||||||
component: () => import('@/views/DashboardView.vue'),
|
component: () => import('@/views/DashboardView.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/customers',
|
||||||
|
name: 'customers',
|
||||||
|
component: () => import('@/views/CustomersView.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/satellites',
|
||||||
|
name: 'satellites',
|
||||||
|
component: () => import('@/views/SatellitesView.vue'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/servers',
|
path: '/servers',
|
||||||
name: 'servers',
|
name: 'servers',
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { apiClient } from '@/api/client'
|
import { apiClient } from '@/api/client'
|
||||||
import { disconnectSocket } from '@/api/socket'
|
|
||||||
|
|
||||||
const TOKEN_KEY = 'iu_token'
|
const TOKEN_KEY = 'iu_token'
|
||||||
|
|
||||||
@@ -25,7 +24,6 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
token.value = null
|
token.value = null
|
||||||
username.value = null
|
username.value = null
|
||||||
localStorage.removeItem(TOKEN_KEY)
|
localStorage.removeItem(TOKEN_KEY)
|
||||||
disconnectSocket()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { token, username, isAuthenticated, login, logout }
|
return { token, username, isAuthenticated, login, logout }
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { apiClient } from '@/api/client'
|
||||||
|
import type { Customer } from '@/types'
|
||||||
|
|
||||||
|
export const useCustomersStore = defineStore('customers', () => {
|
||||||
|
const customers = ref<Customer[]>([])
|
||||||
|
const selectedId = ref<number | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
async function fetchCustomers(): Promise<void> {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const { data } = await apiClient.get<Customer[]>('/api/customers')
|
||||||
|
customers.value = data
|
||||||
|
if (selectedId.value === null && data.length > 0) {
|
||||||
|
selectedId.value = data[0].id
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createCustomer(payload: { name: string; slug: string; notes?: string }): Promise<Customer> {
|
||||||
|
const { data } = await apiClient.post<Customer>('/api/customers', payload)
|
||||||
|
customers.value.push(data)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteCustomer(id: number): Promise<void> {
|
||||||
|
await apiClient.delete(`/api/customers/${id}`)
|
||||||
|
customers.value = customers.value.filter((c) => c.id !== id)
|
||||||
|
if (selectedId.value === id) {
|
||||||
|
selectedId.value = customers.value[0]?.id ?? null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function select(id: number): void {
|
||||||
|
selectedId.value = id
|
||||||
|
}
|
||||||
|
|
||||||
|
return { customers, selectedId, loading, fetchCustomers, createCustomer, deleteCustomer, select }
|
||||||
|
})
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { apiClient } from '@/api/client'
|
||||||
|
import type { Satellite, SatelliteCreated } from '@/types'
|
||||||
|
|
||||||
|
export const useSatellitesStore = defineStore('satellites', () => {
|
||||||
|
const satellites = ref<Satellite[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
async function fetchSatellites(customerId?: number): Promise<void> {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const params = customerId ? { customer_id: customerId } : {}
|
||||||
|
const { data } = await apiClient.get<Satellite[]>('/api/satellites', { params })
|
||||||
|
satellites.value = data
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createSatellite(customerId: number, name: string): Promise<SatelliteCreated> {
|
||||||
|
const { data } = await apiClient.post<SatelliteCreated>('/api/satellites', {
|
||||||
|
customer_id: customerId,
|
||||||
|
name,
|
||||||
|
})
|
||||||
|
satellites.value.push(data)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteSatellite(id: number): Promise<void> {
|
||||||
|
await apiClient.delete(`/api/satellites/${id}`)
|
||||||
|
satellites.value = satellites.value.filter((s) => s.id !== id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rotateKey(id: number): Promise<SatelliteCreated> {
|
||||||
|
const { data } = await apiClient.post<SatelliteCreated>(`/api/satellites/${id}/rotate-key`)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
return { satellites, loading, fetchSatellites, createSatellite, deleteSatellite, rotateKey }
|
||||||
|
})
|
||||||
@@ -1,17 +1,17 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { apiClient } from '@/api/client'
|
import { apiClient } from '@/api/client'
|
||||||
import type { Server, HealthResult } from '@/types'
|
import type { Server } from '@/types'
|
||||||
|
|
||||||
export const useServersStore = defineStore('servers', () => {
|
export const useServersStore = defineStore('servers', () => {
|
||||||
const servers = ref<Server[]>([])
|
const servers = ref<Server[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const healthResults = ref<Record<number, HealthResult>>({})
|
|
||||||
|
|
||||||
async function fetchServers(): Promise<void> {
|
async function fetchServers(customerId?: number | null): Promise<void> {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const { data } = await apiClient.get<Server[]>('/api/servers')
|
const params = customerId ? { customer_id: customerId } : {}
|
||||||
|
const { data } = await apiClient.get<Server[]>('/api/servers', { params })
|
||||||
servers.value = data
|
servers.value = data
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
@@ -29,11 +29,5 @@ export const useServersStore = defineStore('servers', () => {
|
|||||||
servers.value = servers.value.filter((s) => s.id !== id)
|
servers.value = servers.value.filter((s) => s.id !== id)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkHealth(id: number): Promise<HealthResult> {
|
return { servers, loading, fetchServers, createServer, deleteServer }
|
||||||
const { data } = await apiClient.get<HealthResult>(`/api/servers/${id}/health`)
|
|
||||||
healthResults.value[id] = data
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
return { servers, loading, healthResults, fetchServers, createServer, deleteServer, checkHealth }
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,90 +1,73 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, onUnmounted } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { apiClient } from '@/api/client'
|
import { apiClient } from '@/api/client'
|
||||||
import { getSocket } from '@/api/socket'
|
|
||||||
import type { UpdateJob, UpdateLogLine, JobType } from '@/types'
|
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', () => {
|
export const useUpdatesStore = defineStore('updates', () => {
|
||||||
const jobs = ref<UpdateJob[]>([])
|
const jobs = ref<UpdateJob[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const liveLogs = ref<Record<number, UpdateLogLine[]>>({})
|
const logs = ref<Record<number, UpdateLogLine[]>>({})
|
||||||
|
|
||||||
async function fetchJobs(): Promise<void> {
|
async function fetchJobs(customerId?: number | null): Promise<void> {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const { data } = await apiClient.get<UpdateJob[]>('/api/updates')
|
const params = customerId ? { customer_id: customerId } : {}
|
||||||
|
const { data } = await apiClient.get<UpdateJob[]>('/api/updates', { params })
|
||||||
jobs.value = data
|
jobs.value = data
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function triggerUpdate(serverId: number, type: JobType): Promise<UpdateJob> {
|
async function triggerUpdate(
|
||||||
|
customerId: number,
|
||||||
|
serverId: number,
|
||||||
|
type: JobType,
|
||||||
|
rebootIfRequired = false,
|
||||||
|
): Promise<UpdateJob> {
|
||||||
const { data } = await apiClient.post<UpdateJob>('/api/updates/trigger', {
|
const { data } = await apiClient.post<UpdateJob>('/api/updates/trigger', {
|
||||||
|
customer_id: customerId,
|
||||||
server_id: serverId,
|
server_id: serverId,
|
||||||
type,
|
type,
|
||||||
|
reboot_if_required: rebootIfRequired,
|
||||||
})
|
})
|
||||||
jobs.value.unshift(data)
|
jobs.value.unshift(data)
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function triggerScan(customerId: number, subnet: string): Promise<UpdateJob> {
|
||||||
|
const { data } = await apiClient.post<UpdateJob>('/api/updates/trigger', {
|
||||||
|
customer_id: customerId,
|
||||||
|
type: 'network_scan',
|
||||||
|
scan_subnet: subnet,
|
||||||
|
})
|
||||||
|
jobs.value.unshift(data)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function triggerBatch(
|
||||||
|
customerId: number,
|
||||||
|
type: JobType,
|
||||||
|
serverIds?: number[],
|
||||||
|
): Promise<number> {
|
||||||
|
const { data } = await apiClient.post<{ created: number }>('/api/updates/trigger-batch', {
|
||||||
|
customer_id: customerId,
|
||||||
|
type,
|
||||||
|
server_ids: serverIds ?? null,
|
||||||
|
})
|
||||||
|
return data.created
|
||||||
|
}
|
||||||
|
|
||||||
async function cancelJob(jobId: number): Promise<void> {
|
async function cancelJob(jobId: number): Promise<void> {
|
||||||
await apiClient.post(`/api/updates/${jobId}/cancel`)
|
await apiClient.post(`/api/updates/${jobId}/cancel`)
|
||||||
|
const job = jobs.value.find((j) => j.id === jobId)
|
||||||
|
if (job) job.status = 'cancelled'
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchLogs(jobId: number, afterId = 0): Promise<void> {
|
async function fetchLogs(jobId: number): Promise<void> {
|
||||||
const { data } = await apiClient.get<UpdateLogLine[]>(`/api/updates/${jobId}/logs`, {
|
const { data } = await apiClient.get<UpdateLogLine[]>(`/api/updates/${jobId}/logs`)
|
||||||
params: { after_id: afterId },
|
logs.value[jobId] = data
|
||||||
})
|
|
||||||
const existing = liveLogs.value[jobId] || []
|
|
||||||
liveLogs.value[jobId] = afterId === 0 ? data : [...existing, ...data]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function subscribeJob(jobId: number): void {
|
return { jobs, loading, logs, fetchJobs, triggerUpdate, triggerScan, triggerBatch, cancelJob, fetchLogs }
|
||||||
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 }
|
|
||||||
})
|
})
|
||||||
|
|||||||
+37
-13
@@ -1,30 +1,61 @@
|
|||||||
export type ServerType = 'windows' | 'linux' | 'cau_cluster'
|
export type ServerType = 'windows' | 'linux' | 'cau_cluster'
|
||||||
export type JobStatus = 'pending' | 'running' | 'success' | 'failed' | 'cancelled'
|
export type JobStatus = 'pending' | 'claimed' | 'running' | 'success' | 'failed' | 'cancelled'
|
||||||
export type JobType = 'windows_update' | 'linux_update' | 'cau_run' | 'health_check'
|
export type JobType = 'windows_update' | 'linux_update' | 'cau_run' | 'health_check' | 'network_scan'
|
||||||
|
|
||||||
|
export interface Customer {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
slug: string
|
||||||
|
notes: string | null
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Satellite {
|
||||||
|
id: number
|
||||||
|
customer_id: number
|
||||||
|
name: string
|
||||||
|
api_key_prefix: string
|
||||||
|
is_active: boolean
|
||||||
|
last_seen_at: string | null
|
||||||
|
version: string | null
|
||||||
|
hostname: string | null
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SatelliteCreated extends Satellite {
|
||||||
|
api_key: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface Server {
|
export interface Server {
|
||||||
id: number
|
id: number
|
||||||
|
customer_id: number
|
||||||
name: string
|
name: string
|
||||||
hostname: string
|
hostname: string
|
||||||
port: number
|
port: number
|
||||||
type: ServerType
|
type: ServerType
|
||||||
description: string | null
|
description: string | null
|
||||||
tags: string | null
|
tags: string | null
|
||||||
credential_id: number | null
|
credential_ref: string | null
|
||||||
last_health_at: string | null
|
last_health_at: string | null
|
||||||
last_health_ok: boolean | null
|
last_health_ok: boolean | null
|
||||||
|
last_health_message: string | null
|
||||||
|
discovered_by_scan: boolean
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateJob {
|
export interface UpdateJob {
|
||||||
id: number
|
id: number
|
||||||
server_id: number
|
customer_id: number
|
||||||
|
server_id: number | null
|
||||||
|
satellite_id: number | null
|
||||||
type: JobType
|
type: JobType
|
||||||
status: JobStatus
|
status: JobStatus
|
||||||
progress_percent: number
|
progress_percent: number
|
||||||
current_phase: string | null
|
current_phase: string | null
|
||||||
started_by: string
|
created_by: string
|
||||||
|
claimed_at: string | null
|
||||||
started_at: string | null
|
started_at: string | null
|
||||||
finished_at: string | null
|
finished_at: string | null
|
||||||
error: string | null
|
error: string | null
|
||||||
@@ -42,6 +73,7 @@ export interface UpdateLogLine {
|
|||||||
export interface AuditEntry {
|
export interface AuditEntry {
|
||||||
id: number
|
id: number
|
||||||
timestamp: string
|
timestamp: string
|
||||||
|
customer_id: number | null
|
||||||
username: string
|
username: string
|
||||||
action: string
|
action: string
|
||||||
target: string | null
|
target: string | null
|
||||||
@@ -49,11 +81,3 @@ export interface AuditEntry {
|
|||||||
details: string | null
|
details: string | null
|
||||||
ip_address: 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,93 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
|
import { useCustomersStore } from '@/stores/customers'
|
||||||
|
|
||||||
|
const store = useCustomersStore()
|
||||||
|
const showForm = ref(false)
|
||||||
|
const form = reactive({ name: '', slug: '', notes: '' })
|
||||||
|
|
||||||
|
onMounted(() => store.fetchCustomers())
|
||||||
|
|
||||||
|
async function submit(): Promise<void> {
|
||||||
|
await store.createCustomer({ ...form })
|
||||||
|
showForm.value = false
|
||||||
|
form.name = ''
|
||||||
|
form.slug = ''
|
||||||
|
form.notes = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id: number): Promise<void> {
|
||||||
|
if (confirm('Kunde inkl. aller Server und Jobs wirklich löschen?')) {
|
||||||
|
await store.deleteCustomer(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="mb-6 flex items-center justify-between">
|
||||||
|
<h1 class="text-2xl font-bold">Kunden</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' : 'Kunde 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">Slug (a-z, 0-9, -)</label>
|
||||||
|
<input v-model="form.slug" required pattern="[a-z0-9-]+" class="w-full rounded border-slate-300" />
|
||||||
|
</div>
|
||||||
|
<div class="sm:col-span-2">
|
||||||
|
<label class="mb-1 block text-sm font-medium">Notizen</label>
|
||||||
|
<input v-model="form.notes" 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">Slug</th>
|
||||||
|
<th class="px-4 py-2 text-left font-medium">Notizen</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="customer in store.customers" :key="customer.id">
|
||||||
|
<td class="px-4 py-2 font-medium">{{ customer.name }}</td>
|
||||||
|
<td class="px-4 py-2 font-mono text-xs">{{ customer.slug }}</td>
|
||||||
|
<td class="px-4 py-2">{{ customer.notes }}</td>
|
||||||
|
<td class="px-4 py-2 text-right">
|
||||||
|
<button
|
||||||
|
class="rounded bg-red-600 px-2 py-1 text-xs text-white hover:bg-red-500"
|
||||||
|
@click="remove(customer.id)"
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="store.customers.length === 0">
|
||||||
|
<td colspan="4" class="px-4 py-6 text-center text-slate-500">
|
||||||
|
Noch keine Kunden angelegt.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,93 +1,131 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
import { useCustomersStore } from '@/stores/customers'
|
||||||
import { useServersStore } from '@/stores/servers'
|
import { useServersStore } from '@/stores/servers'
|
||||||
|
import { useSatellitesStore } from '@/stores/satellites'
|
||||||
import { useUpdatesStore } from '@/stores/updates'
|
import { useUpdatesStore } from '@/stores/updates'
|
||||||
import { apiClient } from '@/api/client'
|
import { apiClient } from '@/api/client'
|
||||||
|
|
||||||
|
const customersStore = useCustomersStore()
|
||||||
const serversStore = useServersStore()
|
const serversStore = useServersStore()
|
||||||
|
const satellitesStore = useSatellitesStore()
|
||||||
const updatesStore = useUpdatesStore()
|
const updatesStore = useUpdatesStore()
|
||||||
|
|
||||||
const stats = ref<{ total: number; running: number; failed: number }>({
|
const stats = ref<{ total: number; running: number; failed: number; pending: number }>({
|
||||||
total: 0,
|
total: 0,
|
||||||
running: 0,
|
running: 0,
|
||||||
failed: 0,
|
failed: 0,
|
||||||
|
pending: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const customerId = computed(() => customersStore.selectedId)
|
||||||
|
|
||||||
const healthyCount = computed(
|
const healthyCount = computed(
|
||||||
() => serversStore.servers.filter((s) => s.last_health_ok === true).length,
|
() => serversStore.servers.filter((s) => s.last_health_ok === true).length,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const onlineSatellites = computed(
|
||||||
|
() =>
|
||||||
|
satellitesStore.satellites.filter(
|
||||||
|
(s) => s.last_seen_at && Date.now() - new Date(s.last_seen_at).getTime() < 3 * 60 * 1000,
|
||||||
|
).length,
|
||||||
|
)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([
|
await customersStore.fetchCustomers()
|
||||||
serversStore.fetchServers(),
|
await load()
|
||||||
updatesStore.fetchJobs(),
|
|
||||||
apiClient.get('/api/updates/stats/summary').then(({ data }) => (stats.value = data)),
|
|
||||||
])
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(customerId, load)
|
||||||
|
|
||||||
|
async function load(): Promise<void> {
|
||||||
|
if (!customerId.value) return
|
||||||
|
await Promise.all([
|
||||||
|
serversStore.fetchServers(customerId.value),
|
||||||
|
satellitesStore.fetchSatellites(customerId.value),
|
||||||
|
updatesStore.fetchJobs(customerId.value),
|
||||||
|
apiClient
|
||||||
|
.get('/api/updates/stats/summary', { params: { customer_id: customerId.value } })
|
||||||
|
.then(({ data }) => (stats.value = data)),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
success: 'bg-green-100 text-green-800',
|
||||||
|
failed: 'bg-red-100 text-red-800',
|
||||||
|
running: 'bg-blue-100 text-blue-800',
|
||||||
|
claimed: 'bg-amber-100 text-amber-800',
|
||||||
|
pending: 'bg-slate-100 text-slate-800',
|
||||||
|
cancelled: 'bg-slate-100 text-slate-500',
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="mb-6 text-2xl font-bold">Dashboard</h1>
|
<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 v-if="!customerId" class="rounded-lg bg-white p-8 text-center text-slate-500 shadow">
|
||||||
<div class="rounded-lg bg-white p-5 shadow">
|
Noch kein Kunde angelegt - zuerst unter "Kunden" einen Kunden erstellen.
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<h2 class="mb-3 mt-8 text-lg font-semibold">Letzte Jobs</h2>
|
<template v-else>
|
||||||
<div class="overflow-hidden rounded-lg bg-white shadow">
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||||
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
<div class="rounded-lg bg-white p-5 shadow">
|
||||||
<thead class="bg-slate-50">
|
<p class="text-sm text-slate-500">Satelliten online</p>
|
||||||
<tr>
|
<p class="mt-1 text-3xl font-bold" :class="onlineSatellites > 0 ? 'text-green-600' : 'text-red-600'">
|
||||||
<th class="px-4 py-2 text-left font-medium">ID</th>
|
{{ onlineSatellites }}/{{ satellitesStore.satellites.length }}
|
||||||
<th class="px-4 py-2 text-left font-medium">Server</th>
|
</p>
|
||||||
<th class="px-4 py-2 text-left font-medium">Typ</th>
|
</div>
|
||||||
<th class="px-4 py-2 text-left font-medium">Status</th>
|
<div class="rounded-lg bg-white p-5 shadow">
|
||||||
<th class="px-4 py-2 text-left font-medium">Gestartet von</th>
|
<p class="text-sm text-slate-500">Server</p>
|
||||||
</tr>
|
<p class="mt-1 text-3xl font-bold">{{ serversStore.servers.length }}</p>
|
||||||
</thead>
|
</div>
|
||||||
<tbody class="divide-y divide-slate-100">
|
<div class="rounded-lg bg-white p-5 shadow">
|
||||||
<tr v-for="job in updatesStore.jobs.slice(0, 10)" :key="job.id">
|
<p class="text-sm text-slate-500">Jobs wartend</p>
|
||||||
<td class="px-4 py-2">{{ job.id }}</td>
|
<p class="mt-1 text-3xl font-bold text-amber-600">{{ stats.pending }}</p>
|
||||||
<td class="px-4 py-2">{{ job.server_id }}</td>
|
</div>
|
||||||
<td class="px-4 py-2">{{ job.type }}</td>
|
<div class="rounded-lg bg-white p-5 shadow">
|
||||||
<td class="px-4 py-2">
|
<p class="text-sm text-slate-500">Jobs laufend</p>
|
||||||
<span
|
<p class="mt-1 text-3xl font-bold text-blue-600">{{ stats.running }}</p>
|
||||||
class="rounded-full px-2 py-0.5 text-xs font-semibold"
|
</div>
|
||||||
:class="{
|
<div class="rounded-lg bg-white p-5 shadow">
|
||||||
'bg-green-100 text-green-800': job.status === 'success',
|
<p class="text-sm text-slate-500">Jobs fehlgeschlagen</p>
|
||||||
'bg-red-100 text-red-800': job.status === 'failed',
|
<p class="mt-1 text-3xl font-bold text-red-600">{{ stats.failed }}</p>
|
||||||
'bg-blue-100 text-blue-800': job.status === 'running',
|
</div>
|
||||||
'bg-slate-100 text-slate-800': job.status === 'pending' || job.status === 'cancelled',
|
</div>
|
||||||
}"
|
|
||||||
>
|
<h2 class="mb-3 mt-8 text-lg font-semibold">Letzte Jobs</h2>
|
||||||
{{ job.status }}
|
<div class="overflow-hidden rounded-lg bg-white shadow">
|
||||||
</span>
|
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||||
</td>
|
<thead class="bg-slate-50">
|
||||||
<td class="px-4 py-2">{{ job.started_by }}</td>
|
<tr>
|
||||||
</tr>
|
<th class="px-4 py-2 text-left font-medium">ID</th>
|
||||||
<tr v-if="updatesStore.jobs.length === 0">
|
<th class="px-4 py-2 text-left font-medium">Typ</th>
|
||||||
<td colspan="5" class="px-4 py-6 text-center text-slate-500">
|
<th class="px-4 py-2 text-left font-medium">Status</th>
|
||||||
Noch keine Jobs vorhanden.
|
<th class="px-4 py-2 text-left font-medium">Satellite</th>
|
||||||
</td>
|
<th class="px-4 py-2 text-left font-medium">Erstellt von</th>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</thead>
|
||||||
</table>
|
<tbody class="divide-y divide-slate-100">
|
||||||
</div>
|
<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.type }}</td>
|
||||||
|
<td class="px-4 py-2">
|
||||||
|
<span class="rounded-full px-2 py-0.5 text-xs font-semibold" :class="statusColors[job.status]">
|
||||||
|
{{ job.status }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2">{{ job.satellite_id || '-' }}</td>
|
||||||
|
<td class="px-4 py-2">{{ job.created_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>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
import { useCustomersStore } from '@/stores/customers'
|
||||||
|
import { useSatellitesStore } from '@/stores/satellites'
|
||||||
|
import type { SatelliteCreated } from '@/types'
|
||||||
|
|
||||||
|
const customersStore = useCustomersStore()
|
||||||
|
const store = useSatellitesStore()
|
||||||
|
|
||||||
|
const newName = ref('')
|
||||||
|
const newKey = ref<SatelliteCreated | null>(null)
|
||||||
|
const copied = ref(false)
|
||||||
|
|
||||||
|
const customerId = computed(() => customersStore.selectedId)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await customersStore.fetchCustomers()
|
||||||
|
if (customerId.value) await store.fetchSatellites(customerId.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(customerId, async (id) => {
|
||||||
|
if (id) await store.fetchSatellites(id)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function create(): Promise<void> {
|
||||||
|
if (!customerId.value || !newName.value) return
|
||||||
|
newKey.value = await store.createSatellite(customerId.value, newName.value)
|
||||||
|
newName.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rotate(id: number): Promise<void> {
|
||||||
|
if (confirm('Neuen API-Key generieren? Der alte wird sofort ungültig.')) {
|
||||||
|
newKey.value = await store.rotateKey(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id: number): Promise<void> {
|
||||||
|
if (confirm('Satellite wirklich löschen?')) {
|
||||||
|
await store.deleteSatellite(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyKey(): Promise<void> {
|
||||||
|
if (!newKey.value) return
|
||||||
|
await navigator.clipboard.writeText(newKey.value.api_key)
|
||||||
|
copied.value = true
|
||||||
|
setTimeout(() => (copied.value = false), 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOnline(lastSeen: string | null): boolean {
|
||||||
|
if (!lastSeen) return false
|
||||||
|
return Date.now() - new Date(lastSeen).getTime() < 3 * 60 * 1000
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h1 class="mb-6 text-2xl font-bold">Satelliten</h1>
|
||||||
|
|
||||||
|
<div v-if="newKey" class="mb-6 rounded-lg border-2 border-amber-400 bg-amber-50 p-5">
|
||||||
|
<p class="mb-2 font-semibold text-amber-800">
|
||||||
|
API-Key für "{{ newKey.name }}" - wird nur einmal angezeigt!
|
||||||
|
</p>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<code class="flex-1 overflow-x-auto rounded bg-white px-3 py-2 font-mono text-xs">{{ newKey.api_key }}</code>
|
||||||
|
<button class="rounded bg-slate-800 px-3 py-2 text-xs text-white" @click="copyKey">
|
||||||
|
{{ copied ? 'Kopiert!' : 'Kopieren' }}
|
||||||
|
</button>
|
||||||
|
<button class="rounded bg-slate-300 px-3 py-2 text-xs" @click="newKey = null">Schließen</button>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-xs text-amber-700">
|
||||||
|
In config.yaml auf dem Satellite eintragen: <code>api_key: "..."</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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">Neuer Satellite (Kunde: im Header wählen)</label>
|
||||||
|
<input v-model="newName" placeholder="z.B. sat-hauptstandort" class="w-full rounded border-slate-300" />
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
:disabled="!newName || !customerId"
|
||||||
|
class="rounded bg-green-700 px-4 py-2 text-sm font-semibold text-white hover:bg-green-600 disabled:opacity-50"
|
||||||
|
@click="create"
|
||||||
|
>
|
||||||
|
Anlegen
|
||||||
|
</button>
|
||||||
|
</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">Status</th>
|
||||||
|
<th class="px-4 py-2 text-left font-medium">Version</th>
|
||||||
|
<th class="px-4 py-2 text-left font-medium">Hostname</th>
|
||||||
|
<th class="px-4 py-2 text-left font-medium">Key-Prefix</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="sat in store.satellites" :key="sat.id">
|
||||||
|
<td class="px-4 py-2 font-medium">{{ sat.name }}</td>
|
||||||
|
<td class="px-4 py-2">
|
||||||
|
<span v-if="isOnline(sat.last_seen_at)" class="text-green-600">Online</span>
|
||||||
|
<span v-else class="text-slate-400">Offline</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2">{{ sat.version || '-' }}</td>
|
||||||
|
<td class="px-4 py-2">{{ sat.hostname || '-' }}</td>
|
||||||
|
<td class="px-4 py-2 font-mono text-xs">{{ sat.api_key_prefix }}...</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="rotate(sat.id)"
|
||||||
|
>
|
||||||
|
Key rotieren
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="rounded bg-red-600 px-2 py-1 text-xs text-white hover:bg-red-500"
|
||||||
|
@click="remove(sat.id)"
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="store.satellites.length === 0">
|
||||||
|
<td colspan="6" class="px-4 py-6 text-center text-slate-500">
|
||||||
|
Keine Satelliten für diesen Kunden.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||||
|
import { useCustomersStore } from '@/stores/customers'
|
||||||
import { useServersStore } from '@/stores/servers'
|
import { useServersStore } from '@/stores/servers'
|
||||||
|
import { useUpdatesStore } from '@/stores/updates'
|
||||||
import type { ServerType } from '@/types'
|
import type { ServerType } from '@/types'
|
||||||
|
|
||||||
|
const customersStore = useCustomersStore()
|
||||||
const store = useServersStore()
|
const store = useServersStore()
|
||||||
|
const updatesStore = useUpdatesStore()
|
||||||
|
|
||||||
const showForm = ref(false)
|
const showForm = ref(false)
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
@@ -11,6 +15,7 @@ const form = reactive({
|
|||||||
hostname: '',
|
hostname: '',
|
||||||
port: 5985,
|
port: 5985,
|
||||||
type: 'windows' as ServerType,
|
type: 'windows' as ServerType,
|
||||||
|
credential_ref: '',
|
||||||
description: '',
|
description: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -20,15 +25,31 @@ const typeLabels: Record<ServerType, string> = {
|
|||||||
cau_cluster: 'CAU Cluster',
|
cau_cluster: 'CAU Cluster',
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => store.fetchServers())
|
const customerId = computed(() => customersStore.selectedId)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await customersStore.fetchCustomers()
|
||||||
|
if (customerId.value) await store.fetchServers(customerId.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(customerId, async (id) => {
|
||||||
|
if (id) await store.fetchServers(id)
|
||||||
|
})
|
||||||
|
|
||||||
async function submit(): Promise<void> {
|
async function submit(): Promise<void> {
|
||||||
await store.createServer({ ...form })
|
if (!customerId.value) return
|
||||||
|
await store.createServer({
|
||||||
|
...form,
|
||||||
|
customer_id: customerId.value,
|
||||||
|
credential_ref: form.credential_ref || null,
|
||||||
|
description: form.description || null,
|
||||||
|
})
|
||||||
showForm.value = false
|
showForm.value = false
|
||||||
form.name = ''
|
form.name = ''
|
||||||
form.hostname = ''
|
form.hostname = ''
|
||||||
form.port = 5985
|
form.port = 5985
|
||||||
form.type = 'windows'
|
form.type = 'windows'
|
||||||
|
form.credential_ref = ''
|
||||||
form.description = ''
|
form.description = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,6 +58,12 @@ async function remove(id: number): Promise<void> {
|
|||||||
await store.deleteServer(id)
|
await store.deleteServer(id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function healthCheck(id: number): Promise<void> {
|
||||||
|
if (!customerId.value) return
|
||||||
|
await updatesStore.triggerUpdate(customerId.value, id, 'health_check')
|
||||||
|
alert('Health-Check Job wurde in die Queue gelegt. Der Satellite führt ihn aus.')
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -58,7 +85,7 @@ async function remove(id: number): Promise<void> {
|
|||||||
<input v-model="form.name" required class="w-full rounded border-slate-300" />
|
<input v-model="form.name" required class="w-full rounded border-slate-300" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="mb-1 block text-sm font-medium">Hostname / FQDN</label>
|
<label class="mb-1 block text-sm font-medium">Hostname / IP</label>
|
||||||
<input v-model="form.hostname" required class="w-full rounded border-slate-300" />
|
<input v-model="form.hostname" required class="w-full rounded border-slate-300" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -73,7 +100,11 @@ async function remove(id: number): Promise<void> {
|
|||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="sm:col-span-2">
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium">Credential-Ref (Name aus credentials.yaml)</label>
|
||||||
|
<input v-model="form.credential_ref" placeholder="z.B. winrm-admin" class="w-full rounded border-slate-300" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
<label class="mb-1 block text-sm font-medium">Beschreibung</label>
|
<label class="mb-1 block text-sm font-medium">Beschreibung</label>
|
||||||
<input v-model="form.description" class="w-full rounded border-slate-300" />
|
<input v-model="form.description" class="w-full rounded border-slate-300" />
|
||||||
</div>
|
</div>
|
||||||
@@ -92,24 +123,29 @@ async function remove(id: number): Promise<void> {
|
|||||||
<th class="px-4 py-2 text-left font-medium">Name</th>
|
<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">Hostname</th>
|
||||||
<th class="px-4 py-2 text-left font-medium">Typ</th>
|
<th class="px-4 py-2 text-left font-medium">Typ</th>
|
||||||
|
<th class="px-4 py-2 text-left font-medium">Credential</th>
|
||||||
<th class="px-4 py-2 text-left font-medium">Health</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>
|
<th class="px-4 py-2 text-right font-medium">Aktionen</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-slate-100">
|
<tbody class="divide-y divide-slate-100">
|
||||||
<tr v-for="server in store.servers" :key="server.id">
|
<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 font-medium">
|
||||||
|
{{ server.name }}
|
||||||
|
<span v-if="server.discovered_by_scan" class="ml-1 rounded bg-blue-100 px-1 text-xs text-blue-700">Scan</span>
|
||||||
|
</td>
|
||||||
<td class="px-4 py-2">{{ server.hostname }}:{{ server.port }}</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">{{ typeLabels[server.type] }}</td>
|
||||||
<td class="px-4 py-2">
|
<td class="px-4 py-2 font-mono text-xs">{{ server.credential_ref || '-' }}</td>
|
||||||
|
<td class="px-4 py-2" :title="server.last_health_message || ''">
|
||||||
<span v-if="server.last_health_ok === true" class="text-green-600">OK</span>
|
<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-if="server.last_health_ok === false" class="text-red-600">Fehler</span>
|
||||||
<span v-else class="text-slate-400">—</span>
|
<span v-else class="text-slate-400">-</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-2 text-right">
|
<td class="px-4 py-2 text-right">
|
||||||
<button
|
<button
|
||||||
class="mr-2 rounded bg-blue-600 px-2 py-1 text-xs text-white hover:bg-blue-500"
|
class="mr-2 rounded bg-blue-600 px-2 py-1 text-xs text-white hover:bg-blue-500"
|
||||||
@click="store.checkHealth(server.id)"
|
@click="healthCheck(server.id)"
|
||||||
>
|
>
|
||||||
Health-Check
|
Health-Check
|
||||||
</button>
|
</button>
|
||||||
@@ -122,8 +158,8 @@ async function remove(id: number): Promise<void> {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="store.servers.length === 0">
|
<tr v-if="store.servers.length === 0">
|
||||||
<td colspan="5" class="px-4 py-6 text-center text-slate-500">
|
<td colspan="6" class="px-4 py-6 text-center text-slate-500">
|
||||||
Noch keine Server im Inventar.
|
Keine Server für diesen Kunden. Manuell anlegen oder Netzwerk-Scan starten.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
|
import { useCustomersStore } from '@/stores/customers'
|
||||||
import { useServersStore } from '@/stores/servers'
|
import { useServersStore } from '@/stores/servers'
|
||||||
import { useUpdatesStore } from '@/stores/updates'
|
import { useUpdatesStore } from '@/stores/updates'
|
||||||
import type { JobType } from '@/types'
|
import type { JobType } from '@/types'
|
||||||
|
|
||||||
|
const customersStore = useCustomersStore()
|
||||||
const serversStore = useServersStore()
|
const serversStore = useServersStore()
|
||||||
const updatesStore = useUpdatesStore()
|
const updatesStore = useUpdatesStore()
|
||||||
|
|
||||||
const selectedServerId = ref<number | null>(null)
|
const selectedServerId = ref<number | null>(null)
|
||||||
const selectedJobId = ref<number | null>(null)
|
const selectedJobId = ref<number | null>(null)
|
||||||
|
const rebootIfRequired = ref(false)
|
||||||
|
const scanSubnet = ref('')
|
||||||
|
|
||||||
|
const customerId = computed(() => customersStore.selectedId)
|
||||||
|
|
||||||
const jobTypeForServer = computed<JobType>(() => {
|
const jobTypeForServer = computed<JobType>(() => {
|
||||||
const server = serversStore.servers.find((s) => s.id === selectedServerId.value)
|
const server = serversStore.servers.find((s) => s.id === selectedServerId.value)
|
||||||
@@ -19,23 +25,76 @@ const jobTypeForServer = computed<JobType>(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const selectedLogs = computed(() =>
|
const selectedLogs = computed(() =>
|
||||||
selectedJobId.value ? updatesStore.liveLogs[selectedJobId.value] || [] : [],
|
selectedJobId.value ? updatesStore.logs[selectedJobId.value] || [] : [],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
let refreshTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([serversStore.fetchServers(), updatesStore.fetchJobs()])
|
await customersStore.fetchCustomers()
|
||||||
|
if (customerId.value) {
|
||||||
|
await Promise.all([
|
||||||
|
serversStore.fetchServers(customerId.value),
|
||||||
|
updatesStore.fetchJobs(customerId.value),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
refreshTimer = setInterval(refresh, 10000)
|
||||||
})
|
})
|
||||||
|
|
||||||
async function trigger(): Promise<void> {
|
onUnmounted(() => {
|
||||||
if (!selectedServerId.value) return
|
if (refreshTimer) clearInterval(refreshTimer)
|
||||||
const job = await updatesStore.triggerUpdate(selectedServerId.value, jobTypeForServer.value)
|
})
|
||||||
watchJob(job.id)
|
|
||||||
|
watch(customerId, async (id) => {
|
||||||
|
if (id) {
|
||||||
|
selectedJobId.value = null
|
||||||
|
await Promise.all([serversStore.fetchServers(id), updatesStore.fetchJobs(id)])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function refresh(): Promise<void> {
|
||||||
|
if (!customerId.value) return
|
||||||
|
await updatesStore.fetchJobs(customerId.value)
|
||||||
|
if (selectedJobId.value) await updatesStore.fetchLogs(selectedJobId.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function watchJob(jobId: number): Promise<void> {
|
async function trigger(): Promise<void> {
|
||||||
|
if (!selectedServerId.value || !customerId.value) return
|
||||||
|
const job = await updatesStore.triggerUpdate(
|
||||||
|
customerId.value,
|
||||||
|
selectedServerId.value,
|
||||||
|
jobTypeForServer.value,
|
||||||
|
rebootIfRequired.value,
|
||||||
|
)
|
||||||
|
await viewJob(job.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function triggerAll(): Promise<void> {
|
||||||
|
if (!customerId.value) return
|
||||||
|
if (!confirm('Update-Jobs für ALLE Server dieses Kunden anlegen?')) return
|
||||||
|
const created = await updatesStore.triggerBatch(customerId.value, 'windows_update')
|
||||||
|
alert(`${created} Jobs angelegt - der Satellite arbeitet sie der Reihe nach ab.`)
|
||||||
|
await refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startScan(): Promise<void> {
|
||||||
|
if (!customerId.value || !scanSubnet.value) return
|
||||||
|
const job = await updatesStore.triggerScan(customerId.value, scanSubnet.value)
|
||||||
|
await viewJob(job.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function viewJob(jobId: number): Promise<void> {
|
||||||
selectedJobId.value = jobId
|
selectedJobId.value = jobId
|
||||||
await updatesStore.fetchLogs(jobId)
|
await updatesStore.fetchLogs(jobId)
|
||||||
updatesStore.subscribeJob(jobId)
|
}
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
success: 'bg-green-100 text-green-800',
|
||||||
|
failed: 'bg-red-100 text-red-800',
|
||||||
|
running: 'bg-blue-100 text-blue-800',
|
||||||
|
claimed: 'bg-amber-100 text-amber-800',
|
||||||
|
pending: 'bg-slate-100 text-slate-800',
|
||||||
|
cancelled: 'bg-slate-100 text-slate-500',
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -43,28 +102,54 @@ async function watchJob(jobId: number): Promise<void> {
|
|||||||
<div>
|
<div>
|
||||||
<h1 class="mb-6 text-2xl font-bold">Updates</h1>
|
<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="mb-6 grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
<div class="flex-1">
|
<div class="rounded-lg bg-white p-5 shadow">
|
||||||
<label class="mb-1 block text-sm font-medium">Server auswählen</label>
|
<h2 class="mb-3 font-semibold">Einzelnes Update</h2>
|
||||||
<select v-model="selectedServerId" class="w-full rounded border-slate-300">
|
<label class="mb-1 block text-sm font-medium">Server</label>
|
||||||
<option :value="null" disabled>— bitte wählen —</option>
|
<select v-model="selectedServerId" class="mb-3 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">
|
<option v-for="server in serversStore.servers" :key="server.id" :value="server.id">
|
||||||
{{ server.name }} ({{ server.hostname }})
|
{{ server.name }} ({{ server.hostname }})
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
|
<label class="mb-3 flex items-center gap-2 text-sm">
|
||||||
|
<input v-model="rebootIfRequired" type="checkbox" class="rounded" />
|
||||||
|
Reboot falls erforderlich
|
||||||
|
</label>
|
||||||
|
<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 einreihen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg bg-white p-5 shadow">
|
||||||
|
<h2 class="mb-3 font-semibold">Netz & Batch</h2>
|
||||||
|
<label class="mb-1 block text-sm font-medium">Netzwerk-Scan (Subnetz)</label>
|
||||||
|
<div class="mb-3 flex gap-2">
|
||||||
|
<input v-model="scanSubnet" placeholder="192.168.1.0/24" class="flex-1 rounded border-slate-300" />
|
||||||
|
<button
|
||||||
|
:disabled="!scanSubnet"
|
||||||
|
class="rounded bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-500 disabled:opacity-50"
|
||||||
|
@click="startScan"
|
||||||
|
>
|
||||||
|
Scan starten
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="rounded bg-amber-600 px-4 py-2 text-sm font-semibold text-white hover:bg-amber-500"
|
||||||
|
@click="triggerAll"
|
||||||
|
>
|
||||||
|
Alle Windows-Server updaten
|
||||||
|
</button>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||||
<div class="overflow-hidden rounded-lg bg-white shadow">
|
<div class="overflow-hidden rounded-lg bg-white shadow">
|
||||||
<h2 class="border-b bg-slate-50 px-4 py-2 font-semibold">Jobs</h2>
|
<h2 class="border-b bg-slate-50 px-4 py-2 font-semibold">Jobs (auto-refresh 10s)</h2>
|
||||||
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||||
<tbody class="divide-y divide-slate-100">
|
<tbody class="divide-y divide-slate-100">
|
||||||
<tr
|
<tr
|
||||||
@@ -72,23 +157,28 @@ async function watchJob(jobId: number): Promise<void> {
|
|||||||
:key="job.id"
|
:key="job.id"
|
||||||
class="cursor-pointer hover:bg-slate-50"
|
class="cursor-pointer hover:bg-slate-50"
|
||||||
:class="{ 'bg-blue-50': job.id === selectedJobId }"
|
:class="{ 'bg-blue-50': job.id === selectedJobId }"
|
||||||
@click="watchJob(job.id)"
|
@click="viewJob(job.id)"
|
||||||
>
|
>
|
||||||
<td class="px-4 py-2">#{{ job.id }}</td>
|
<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.type }}</td>
|
||||||
<td class="px-4 py-2">{{ job.status }}</td>
|
<td class="px-4 py-2">
|
||||||
|
<span class="rounded-full px-2 py-0.5 text-xs font-semibold" :class="statusColors[job.status]">
|
||||||
|
{{ job.status }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2 text-xs text-slate-500">{{ job.progress_percent }}%</td>
|
||||||
<td class="px-4 py-2 text-right">
|
<td class="px-4 py-2 text-right">
|
||||||
<button
|
<button
|
||||||
v-if="job.status === 'running' || job.status === 'pending'"
|
v-if="job.status === 'pending'"
|
||||||
class="rounded bg-red-600 px-2 py-1 text-xs text-white hover:bg-red-500"
|
class="rounded bg-red-600 px-2 py-1 text-xs text-white hover:bg-red-500"
|
||||||
@click.stop="updatesStore.cancelJob(job.id)"
|
@click.stop="updatesStore.cancelJob(job.id)"
|
||||||
>
|
>
|
||||||
Abbrechen
|
Stornieren
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="updatesStore.jobs.length === 0">
|
<tr v-if="updatesStore.jobs.length === 0">
|
||||||
<td colspan="4" class="px-4 py-6 text-center text-slate-500">Keine Jobs.</td>
|
<td colspan="5" class="px-4 py-6 text-center text-slate-500">Keine Jobs.</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -96,13 +186,15 @@ async function watchJob(jobId: number): Promise<void> {
|
|||||||
|
|
||||||
<div class="rounded-lg bg-slate-900 p-4 font-mono text-xs text-green-300 shadow">
|
<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">
|
<h2 class="mb-2 font-sans text-sm font-semibold text-slate-300">
|
||||||
Live-Log {{ selectedJobId ? `(Job #${selectedJobId})` : '' }}
|
Log {{ selectedJobId ? `(Job #${selectedJobId})` : '' }}
|
||||||
</h2>
|
</h2>
|
||||||
<div class="max-h-96 overflow-y-auto whitespace-pre-wrap">
|
<div class="max-h-96 overflow-y-auto whitespace-pre-wrap">
|
||||||
<p v-if="selectedLogs.length === 0" class="text-slate-500">
|
<p v-if="selectedLogs.length === 0" class="text-slate-500">
|
||||||
Kein Job ausgewählt — klicke links einen Job an.
|
Kein Job ausgewählt - klicke links einen Job an.
|
||||||
|
</p>
|
||||||
|
<p v-for="log in selectedLogs" :key="log.id" :class="{ 'text-red-400': log.level === 'error' }">
|
||||||
|
{{ log.line }}
|
||||||
</p>
|
</p>
|
||||||
<p v-for="log in selectedLogs" :key="log.id">{{ log.line }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# Insight Updater Satellite
|
||||||
|
|
||||||
|
Remote-Agent fuer Kundennetzwerke. Laedt Jobs von der zentralen Insight-Updater-Instanz,
|
||||||
|
fuehrt sie lokal im Kundennetz aus (WinRM / SSH / CAU / Netzwerk-Scan) und meldet
|
||||||
|
Logs und Ergebnisse zurueck. Kein Docker noetig - eine einzelne Binary genuegt.
|
||||||
|
|
||||||
|
## Prinzip
|
||||||
|
|
||||||
|
- Nur ausgehende HTTPS-Verbindungen zur Zentrale (keine Firewall-Loecher beim Kunden)
|
||||||
|
- Pull-Modell: der Satellite pollt alle N Sekunden nach Jobs
|
||||||
|
- Credentials (WinRM/SSH) liegen ausschliesslich lokal in `credentials.yaml`
|
||||||
|
- 1-2 Satelliten pro Kunde reichen - sie steuern das ganze Netz (wie CAU im Cluster)
|
||||||
|
|
||||||
|
## Setup (Development)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd satellite
|
||||||
|
pip install -e .
|
||||||
|
cp config.example.yaml config.yaml
|
||||||
|
cp credentials.example.yaml credentials.yaml
|
||||||
|
# config.yaml: central_url + api_key eintragen (Key aus dem Dashboard)
|
||||||
|
# credentials.yaml: WinRM-/SSH-Zugangsdaten des Kundennetzes pflegen
|
||||||
|
insight-satellite --config config.yaml --credentials credentials.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
## Windows-Binary bauen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -e ".[build]"
|
||||||
|
pyinstaller --onefile --name insight-satellite satellite/runner.py
|
||||||
|
# Ergebnis: dist/insight-satellite.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
Auf dem Zielsystem (Windows-Server beim Kunden):
|
||||||
|
|
||||||
|
```
|
||||||
|
C:\insight-satellite\
|
||||||
|
insight-satellite.exe
|
||||||
|
config.yaml
|
||||||
|
credentials.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
Start als Scheduled Task (Beispiel, ohne Umlaute):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
schtasks /create /tn "InsightUpdaterSatellite" /tr "C:\insight-satellite\insight-satellite.exe --config C:\insight-satellite\config.yaml --credentials C:\insight-satellite\credentials.yaml" /sc onstart /ru SYSTEM /rl HIGHEST
|
||||||
|
```
|
||||||
|
|
||||||
|
## Job-Typen
|
||||||
|
|
||||||
|
| Typ | Aktion |
|
||||||
|
|---|---|
|
||||||
|
| `windows_update` | Windows Update via WinRM (COM Microsoft.Update.Session) |
|
||||||
|
| `linux_update` | apt/dnf/yum upgrade via SSH mit sudo |
|
||||||
|
| `cau_run` | Invoke-CauRun auf einem Failover-Cluster |
|
||||||
|
| `health_check` | Verbindungstest, Ergebnis geht an die Zentrale |
|
||||||
|
| `network_scan` | Ping-Sweep + Port-Probe (5985/22), legt gefundene Hosts zentral an |
|
||||||
|
|
||||||
|
## Ablauf pro Job
|
||||||
|
|
||||||
|
1. `GET /api/satellite/poll` - Jobs abholen (werden dabei claimed)
|
||||||
|
2. Lokal ausfuehren, Log-Zeilen sammeln
|
||||||
|
3. `POST /api/satellite/logs` - Batches waehrend der Ausfuehrung
|
||||||
|
4. `POST /api/satellite/result` - Abschluss (success/failed + Fehlertext)
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Insight Updater Satellite
|
||||||
|
# Zentrale Verbindung
|
||||||
|
central_url: https://updater.insight-it.de
|
||||||
|
api_key: "ius_..." # API-Key aus dem Dashboard (Satellite anlegen)
|
||||||
|
|
||||||
|
# Verhalten
|
||||||
|
poll_interval: 30 # Sekunden zwischen Job-Polls
|
||||||
|
heartbeat_interval: 60 # Sekunden zwischen Heartbeats
|
||||||
|
log_batch_size: 50 # Log-Zeilen pro Upload
|
||||||
|
log_flush_interval: 10 # Sekunden, nach denen Log-Puffer geflusht wird
|
||||||
|
|
||||||
|
# WinRM-Defaults (koennen pro Credential ueberschrieben werden)
|
||||||
|
winrm_transport: ntlm # ntlm | kerberos | credssp
|
||||||
|
winrm_cert_validation: ignore
|
||||||
|
|
||||||
|
# Netzwerk-Scan Defaults
|
||||||
|
scan_default_subnet: "" # z.B. 192.168.1.0/24 - leer = Job-Parameter pflicht
|
||||||
|
scan_ping_timeout_ms: 500
|
||||||
|
scan_port_timeout_ms: 1500
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Lokale Credentials - NUR auf dem Satellite, niemals zentral!
|
||||||
|
# Server in der Zentrale referenzieren diese Namen ueber "credential_ref".
|
||||||
|
#
|
||||||
|
# Beispiele:
|
||||||
|
winrm-admin:
|
||||||
|
type: winrm
|
||||||
|
username: "DOMAIN\\svc_update"
|
||||||
|
password: "geheim"
|
||||||
|
# transport: ntlm # optional, ueberschreibt config.yaml
|
||||||
|
|
||||||
|
linux-root:
|
||||||
|
type: ssh
|
||||||
|
username: "root"
|
||||||
|
password: "geheim"
|
||||||
|
|
||||||
|
linux-key:
|
||||||
|
type: ssh
|
||||||
|
username: "update"
|
||||||
|
private_key_path: "/etc/insight-satellite/id_ed25519"
|
||||||
|
passphrase: ""
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
[project]
|
||||||
|
name = "insight-updater-satellite"
|
||||||
|
version = "0.2.0"
|
||||||
|
description = "Insight Updater Satellite - remote update agent for customer networks"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"httpx>=0.26.0",
|
||||||
|
"pywinrm>=0.4.3",
|
||||||
|
"asyncssh>=2.14.0",
|
||||||
|
"pyyaml>=6.0.1",
|
||||||
|
"structlog>=24.1.0",
|
||||||
|
"tenacity>=8.2.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
kerberos = ["pywinrm[kerberos]>=0.4.3"]
|
||||||
|
build = ["pyinstaller>=6.0.0"]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
insight-satellite = "satellite.runner:main"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=68.0", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
include = ["satellite*"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
target-version = "py311"
|
||||||
|
line-length = 100
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""Insight Updater Satellite - remote agent for customer networks."""
|
||||||
|
|
||||||
|
__version__ = "0.2.0"
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""CAU executor: Cluster-Aware Updating via PowerShell remoting."""
|
||||||
|
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
|
||||||
|
from satellite.config import Config, Credential
|
||||||
|
from satellite.winrm_exec import Target, WinRMExecutor
|
||||||
|
|
||||||
|
|
||||||
|
class CAUError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class CAUExecutor:
|
||||||
|
def __init__(self, config: Config, cluster_name: str, port: int, credential: Credential) -> None:
|
||||||
|
self.cluster_name = cluster_name
|
||||||
|
self.winrm = WinRMExecutor(config, Target(cluster_name, port), credential)
|
||||||
|
|
||||||
|
async def invoke_cau_run(self) -> AsyncIterator[str]:
|
||||||
|
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:
|
||||||
|
raise CAUError(f"CAU-Lauf fehlgeschlagen: {exc}") from exc
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""HTTP client for the central Insight Updater API."""
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from satellite.config import Config
|
||||||
|
|
||||||
|
|
||||||
|
class CentralClient:
|
||||||
|
def __init__(self, config: Config, version: str) -> None:
|
||||||
|
self.config = config
|
||||||
|
self.version = version
|
||||||
|
self._client = httpx.AsyncClient(
|
||||||
|
base_url=config.central_url,
|
||||||
|
headers={"X-Api-Key": config.api_key},
|
||||||
|
timeout=httpx.Timeout(30.0),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
await self._client.aclose()
|
||||||
|
|
||||||
|
async def heartbeat(self, hostname: str) -> None:
|
||||||
|
r = await self._client.post(
|
||||||
|
"/api/satellite/heartbeat",
|
||||||
|
json={"version": self.version, "hostname": hostname},
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
|
||||||
|
async def poll(self) -> list[dict[str, Any]]:
|
||||||
|
r = await self._client.get("/api/satellite/poll")
|
||||||
|
r.raise_for_status()
|
||||||
|
return list(r.json().get("jobs", []))
|
||||||
|
|
||||||
|
async def push_logs(
|
||||||
|
self,
|
||||||
|
job_id: int,
|
||||||
|
lines: list[tuple[str, str]], # (level, line)
|
||||||
|
progress_percent: int | None = None,
|
||||||
|
current_phase: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"job_id": job_id,
|
||||||
|
"lines": [
|
||||||
|
{"timestamp": datetime.now(UTC).isoformat(), "level": lvl, "line": ln}
|
||||||
|
for lvl, ln in lines
|
||||||
|
],
|
||||||
|
}
|
||||||
|
if progress_percent is not None:
|
||||||
|
payload["progress_percent"] = progress_percent
|
||||||
|
if current_phase is not None:
|
||||||
|
payload["current_phase"] = current_phase
|
||||||
|
r = await self._client.post("/api/satellite/logs", json=payload)
|
||||||
|
r.raise_for_status()
|
||||||
|
|
||||||
|
async def push_result(self, job_id: int, success: bool, error: str | None = None) -> None:
|
||||||
|
r = await self._client.post(
|
||||||
|
"/api/satellite/result",
|
||||||
|
json={
|
||||||
|
"job_id": job_id,
|
||||||
|
"status": "success" if success else "failed",
|
||||||
|
"error": error,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
|
||||||
|
async def push_scan_result(self, job_id: int, hosts: list[dict[str, Any]]) -> None:
|
||||||
|
r = await self._client.post(
|
||||||
|
"/api/satellite/scan-result", json={"job_id": job_id, "hosts": hosts}
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
|
||||||
|
async def push_health_report(self, server_id: int, ok: bool, message: str) -> None:
|
||||||
|
r = await self._client.post(
|
||||||
|
"/api/satellite/health-report",
|
||||||
|
json={"server_id": server_id, "ok": ok, "message": message},
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""Satellite configuration: config.yaml + credentials.yaml loading."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Credential:
|
||||||
|
name: str
|
||||||
|
type: str # "winrm" | "ssh"
|
||||||
|
username: str
|
||||||
|
password: str | None = None
|
||||||
|
private_key_path: str | None = None
|
||||||
|
passphrase: str | None = None
|
||||||
|
transport: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Config:
|
||||||
|
central_url: str
|
||||||
|
api_key: str
|
||||||
|
poll_interval: int = 30
|
||||||
|
heartbeat_interval: int = 60
|
||||||
|
log_batch_size: int = 50
|
||||||
|
log_flush_interval: int = 10
|
||||||
|
winrm_transport: str = "ntlm"
|
||||||
|
winrm_cert_validation: str = "ignore"
|
||||||
|
scan_default_subnet: str = ""
|
||||||
|
scan_ping_timeout_ms: int = 500
|
||||||
|
scan_port_timeout_ms: int = 1500
|
||||||
|
credentials: dict[str, Credential] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def credential(self, ref: str | None) -> Credential | None:
|
||||||
|
if not ref:
|
||||||
|
return None
|
||||||
|
cred = self.credentials.get(ref)
|
||||||
|
if cred is None:
|
||||||
|
raise KeyError(f"Credential '{ref}' nicht in credentials.yaml gefunden")
|
||||||
|
return cred
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(config_path: str = "config.yaml", credentials_path: str = "credentials.yaml") -> Config:
|
||||||
|
raw = yaml.safe_load(Path(config_path).read_text(encoding="utf-8")) or {}
|
||||||
|
|
||||||
|
creds: dict[str, Credential] = {}
|
||||||
|
cred_file = Path(credentials_path)
|
||||||
|
if cred_file.exists():
|
||||||
|
raw_creds = yaml.safe_load(cred_file.read_text(encoding="utf-8")) or {}
|
||||||
|
for name, c in raw_creds.items():
|
||||||
|
creds[name] = Credential(name=name, **c)
|
||||||
|
|
||||||
|
return Config(
|
||||||
|
central_url=raw["central_url"].rstrip("/"),
|
||||||
|
api_key=raw["api_key"],
|
||||||
|
poll_interval=int(raw.get("poll_interval", 30)),
|
||||||
|
heartbeat_interval=int(raw.get("heartbeat_interval", 60)),
|
||||||
|
log_batch_size=int(raw.get("log_batch_size", 50)),
|
||||||
|
log_flush_interval=int(raw.get("log_flush_interval", 10)),
|
||||||
|
winrm_transport=raw.get("winrm_transport", "ntlm"),
|
||||||
|
winrm_cert_validation=raw.get("winrm_cert_validation", "ignore"),
|
||||||
|
scan_default_subnet=raw.get("scan_default_subnet", ""),
|
||||||
|
scan_ping_timeout_ms=int(raw.get("scan_ping_timeout_ms", 500)),
|
||||||
|
scan_port_timeout_ms=int(raw.get("scan_port_timeout_ms", 1500)),
|
||||||
|
credentials=creds,
|
||||||
|
)
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
"""Satellite runner: main loop - heartbeat, poll, execute, report."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
from satellite import __version__
|
||||||
|
from satellite.cau_exec import CAUExecutor
|
||||||
|
from satellite.client import CentralClient
|
||||||
|
from satellite.config import Config, load_config
|
||||||
|
from satellite.scanner import scan_subnet
|
||||||
|
from satellite.ssh_exec import SSHExecutor
|
||||||
|
from satellite.ssh_exec import Target as SSHTarget
|
||||||
|
from satellite.winrm_exec import Target as WinRMTarget
|
||||||
|
from satellite.winrm_exec import WinRMExecutor
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class LogBuffer:
|
||||||
|
"""Collects log lines and flushes them in batches to the central."""
|
||||||
|
|
||||||
|
def __init__(self, client: CentralClient, config: Config, job_id: int) -> None:
|
||||||
|
self.client = client
|
||||||
|
self.config = config
|
||||||
|
self.job_id = job_id
|
||||||
|
self.lines: list[tuple[str, str]] = []
|
||||||
|
self.last_flush = time.monotonic()
|
||||||
|
|
||||||
|
async def add(self, line: str, level: str = "info") -> None:
|
||||||
|
self.lines.append((level, line))
|
||||||
|
logger.info("job.log", job_id=self.job_id, line=line)
|
||||||
|
if (
|
||||||
|
len(self.lines) >= self.config.log_batch_size
|
||||||
|
or time.monotonic() - self.last_flush >= self.config.log_flush_interval
|
||||||
|
):
|
||||||
|
await self.flush()
|
||||||
|
|
||||||
|
async def flush(self, progress: int | None = None, phase: str | None = None) -> None:
|
||||||
|
if not self.lines and progress is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
await self.client.push_logs(
|
||||||
|
self.job_id, self.lines, progress_percent=progress, current_phase=phase
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001 - nicht wegen Log-Upload abbrechen
|
||||||
|
logger.warning("log_upload_failed", error=str(exc))
|
||||||
|
self.lines = []
|
||||||
|
self.last_flush = time.monotonic()
|
||||||
|
|
||||||
|
|
||||||
|
async def execute_job(client: CentralClient, config: Config, job: dict[str, Any]) -> None:
|
||||||
|
job_id: int = job["job_id"]
|
||||||
|
buf = LogBuffer(client, config, job_id)
|
||||||
|
await buf.add(f"Job #{job_id} gestartet: {job['type']}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
if job["type"] == "network_scan":
|
||||||
|
await _run_scan(client, config, job, buf)
|
||||||
|
elif job["type"] == "health_check":
|
||||||
|
await _run_health_check(client, config, job, buf)
|
||||||
|
elif job["type"] == "linux_update":
|
||||||
|
await _run_linux_update(config, job, buf)
|
||||||
|
elif job["type"] == "windows_update":
|
||||||
|
await _run_windows_update(config, job, buf)
|
||||||
|
elif job["type"] == "cau_run":
|
||||||
|
await _run_cau(config, job, buf)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unbekannter Job-Typ: {job['type']}")
|
||||||
|
|
||||||
|
await buf.flush(progress=100)
|
||||||
|
await client.push_result(job_id, success=True)
|
||||||
|
logger.info("job.done", job_id=job_id)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
await buf.add(f"FEHLER: {exc}", level="error")
|
||||||
|
await buf.flush()
|
||||||
|
await client.push_result(job_id, success=False, error=str(exc))
|
||||||
|
logger.error("job.failed", job_id=job_id, error=str(exc))
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_scan(client: CentralClient, config: Config, job: dict, buf: LogBuffer) -> None:
|
||||||
|
subnet = job.get("scan_subnet") or config.scan_default_subnet
|
||||||
|
if not subnet:
|
||||||
|
raise ValueError("Kein Subnetz angegeben (scan_subnet oder scan_default_subnet)")
|
||||||
|
await buf.add(f"Scanne Subnetz {subnet} ...")
|
||||||
|
hosts = await scan_subnet(subnet, config.scan_ping_timeout_ms, config.scan_port_timeout_ms)
|
||||||
|
await buf.add(f"{len(hosts)} verwaltbare Hosts gefunden")
|
||||||
|
await buf.flush(progress=90)
|
||||||
|
await client.push_scan_result(job["job_id"], hosts)
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_health_check(client: CentralClient, config: Config, job: dict, buf: LogBuffer) -> None:
|
||||||
|
ok, message = await _test_connection(config, job)
|
||||||
|
await buf.add(message, level="info" if ok else "error")
|
||||||
|
server_id = job.get("server_id")
|
||||||
|
if server_id:
|
||||||
|
await client.push_health_report(server_id, ok, message)
|
||||||
|
if not ok:
|
||||||
|
raise ConnectionError(message)
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_linux_update(config: Config, job: dict, buf: LogBuffer) -> None:
|
||||||
|
cred = config.credential(job.get("credential_ref"))
|
||||||
|
if not cred or cred.type != "ssh":
|
||||||
|
raise ValueError("SSH-Credential erforderlich")
|
||||||
|
executor = SSHExecutor(SSHTarget(job["hostname"], job.get("port") or 22), cred)
|
||||||
|
async for line in executor.stream_updates(job.get("reboot_if_required", False)):
|
||||||
|
await buf.add(line)
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_windows_update(config: Config, job: dict, buf: LogBuffer) -> None:
|
||||||
|
cred = config.credential(job.get("credential_ref"))
|
||||||
|
if not cred or cred.type != "winrm":
|
||||||
|
raise ValueError("WinRM-Credential erforderlich")
|
||||||
|
executor = WinRMExecutor(config, WinRMTarget(job["hostname"], job.get("port") or 5985), cred)
|
||||||
|
async for line in executor.install_updates(job.get("reboot_if_required", False)):
|
||||||
|
await buf.add(line)
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_cau(config: Config, job: dict, buf: LogBuffer) -> None:
|
||||||
|
cred = config.credential(job.get("credential_ref"))
|
||||||
|
if not cred or cred.type != "winrm":
|
||||||
|
raise ValueError("WinRM-Credential erforderlich")
|
||||||
|
executor = CAUExecutor(config, job["hostname"], job.get("port") or 5985, cred)
|
||||||
|
async for line in executor.invoke_cau_run():
|
||||||
|
await buf.add(line)
|
||||||
|
|
||||||
|
|
||||||
|
async def _test_connection(config: Config, job: dict) -> tuple[bool, str]:
|
||||||
|
cred = config.credential(job.get("credential_ref"))
|
||||||
|
if cred is None:
|
||||||
|
return False, "Kein Credential referenziert"
|
||||||
|
if cred.type == "ssh":
|
||||||
|
executor = SSHExecutor(SSHTarget(job["hostname"], job.get("port") or 22), cred)
|
||||||
|
return await executor.test_connection()
|
||||||
|
executor = WinRMExecutor(config, WinRMTarget(job["hostname"], job.get("port") or 5985), cred)
|
||||||
|
return await executor.test_connection()
|
||||||
|
|
||||||
|
|
||||||
|
async def run(config_path: str, credentials_path: str) -> None:
|
||||||
|
config = load_config(config_path, credentials_path)
|
||||||
|
client = CentralClient(config, __version__)
|
||||||
|
hostname = socket.gethostname()
|
||||||
|
last_heartbeat = 0.0
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"satellite.starting",
|
||||||
|
version=__version__,
|
||||||
|
central=config.central_url,
|
||||||
|
credentials=len(config.credentials),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
now = time.monotonic()
|
||||||
|
if now - last_heartbeat >= config.heartbeat_interval:
|
||||||
|
try:
|
||||||
|
await client.heartbeat(hostname)
|
||||||
|
last_heartbeat = now
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.warning("heartbeat_failed", error=str(exc))
|
||||||
|
|
||||||
|
try:
|
||||||
|
jobs = await client.poll()
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.warning("poll_failed", error=str(exc))
|
||||||
|
await asyncio.sleep(config.poll_interval)
|
||||||
|
continue
|
||||||
|
|
||||||
|
for job in jobs:
|
||||||
|
await execute_job(client, config, job)
|
||||||
|
|
||||||
|
if not jobs:
|
||||||
|
await asyncio.sleep(config.poll_interval)
|
||||||
|
finally:
|
||||||
|
await client.close()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description="Insight Updater Satellite")
|
||||||
|
parser.add_argument("--config", default="config.yaml")
|
||||||
|
parser.add_argument("--credentials", default="credentials.yaml")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
structlog.configure(
|
||||||
|
processors=[
|
||||||
|
structlog.processors.TimeStamper(fmt="iso"),
|
||||||
|
structlog.processors.add_log_level,
|
||||||
|
structlog.dev.ConsoleRenderer(),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
asyncio.run(run(args.config, args.credentials))
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""Network scanner: ping sweep + WinRM/SSH port probe for auto-discovery."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import ipaddress
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
|
||||||
|
async def _ping(ip: str, timeout_ms: int) -> bool:
|
||||||
|
if not shutil.which("ping"):
|
||||||
|
return True # kein ping verfuegbar -> trotzdem Port-Check versuchen
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
"ping", "-c", "1", "-W", str(max(1, timeout_ms // 1000)), ip,
|
||||||
|
stdout=asyncio.subprocess.DEVNULL,
|
||||||
|
stderr=asyncio.subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
await proc.wait()
|
||||||
|
return proc.returncode == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def _port_open(ip: str, port: int, timeout_ms: int) -> bool:
|
||||||
|
try:
|
||||||
|
_reader, writer = await asyncio.wait_for(
|
||||||
|
asyncio.open_connection(ip, port), timeout=timeout_ms / 1000
|
||||||
|
)
|
||||||
|
writer.close()
|
||||||
|
await writer.wait_closed()
|
||||||
|
return True
|
||||||
|
except (TimeoutError, OSError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve(ip: str) -> str:
|
||||||
|
def _do() -> str:
|
||||||
|
import socket
|
||||||
|
|
||||||
|
try:
|
||||||
|
return socket.gethostbyaddr(ip)[0]
|
||||||
|
except OSError:
|
||||||
|
return ip
|
||||||
|
|
||||||
|
return await asyncio.to_thread(_do)
|
||||||
|
|
||||||
|
|
||||||
|
async def scan_subnet(subnet: str, ping_timeout_ms: int, port_timeout_ms: int) -> list[dict]:
|
||||||
|
"""Scan a subnet; return hosts with winrm_open / ssh_open flags."""
|
||||||
|
network = ipaddress.ip_network(subnet, strict=False)
|
||||||
|
hosts: list[dict] = []
|
||||||
|
sem = asyncio.Semaphore(64)
|
||||||
|
|
||||||
|
async def probe(ip: str) -> None:
|
||||||
|
async with sem:
|
||||||
|
if not await _ping(ip, ping_timeout_ms):
|
||||||
|
return
|
||||||
|
winrm, ssh = await asyncio.gather(
|
||||||
|
_port_open(ip, 5985, port_timeout_ms),
|
||||||
|
_port_open(ip, 22, port_timeout_ms),
|
||||||
|
)
|
||||||
|
if not winrm and not ssh:
|
||||||
|
return
|
||||||
|
hostname = await _resolve(ip)
|
||||||
|
hosts.append(
|
||||||
|
{
|
||||||
|
"hostname": hostname,
|
||||||
|
"ip": ip,
|
||||||
|
"os_guess": "windows" if winrm else ("linux" if ssh else None),
|
||||||
|
"winrm_open": winrm,
|
||||||
|
"ssh_open": ssh,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
await asyncio.gather(*[probe(str(ip)) for ip in network.hosts()])
|
||||||
|
return sorted(hosts, key=lambda h: h["ip"])
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""SSH executor: run updates on Linux targets via asyncssh."""
|
||||||
|
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from satellite.config import Credential
|
||||||
|
|
||||||
|
|
||||||
|
class SSHError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Target:
|
||||||
|
hostname: str
|
||||||
|
port: int = 22
|
||||||
|
|
||||||
|
|
||||||
|
class SSHExecutor:
|
||||||
|
def __init__(self, target: Target, credential: Credential) -> None:
|
||||||
|
self.target = target
|
||||||
|
self.credential = credential
|
||||||
|
|
||||||
|
def _connect_kwargs(self) -> dict:
|
||||||
|
kwargs: dict = {
|
||||||
|
"host": self.target.hostname,
|
||||||
|
"port": self.target.port,
|
||||||
|
"known_hosts": None,
|
||||||
|
"connect_timeout": 30,
|
||||||
|
"username": self.credential.username,
|
||||||
|
}
|
||||||
|
if self.credential.password:
|
||||||
|
kwargs["password"] = self.credential.password
|
||||||
|
if self.credential.private_key_path:
|
||||||
|
kwargs["client_keys"] = [self.credential.private_key_path]
|
||||||
|
if self.credential.passphrase:
|
||||||
|
kwargs["passphrase"] = self.credential.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 {str(result.stdout).strip()}"
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return False, str(exc)
|
||||||
|
|
||||||
|
async def stream_updates(self, reboot_if_required: bool = False) -> AsyncIterator[str]:
|
||||||
|
import asyncssh
|
||||||
|
|
||||||
|
async with asyncssh.connect(**self._connect_kwargs()) as conn:
|
||||||
|
pm = None
|
||||||
|
for candidate in ("apt-get", "dnf", "yum"):
|
||||||
|
result = await conn.run(f"command -v {candidate}", check=False)
|
||||||
|
if result.returncode == 0:
|
||||||
|
pm = candidate
|
||||||
|
break
|
||||||
|
if not pm:
|
||||||
|
raise SSHError("Kein unterstuetzter Paketmanager gefunden (apt/dnf/yum)")
|
||||||
|
|
||||||
|
yield f"Paketmanager: {pm}"
|
||||||
|
if pm == "apt-get":
|
||||||
|
cmd = "apt-get update && DEBIAN_FRONTEND=noninteractive apt-get upgrade -y"
|
||||||
|
else:
|
||||||
|
cmd = f"{pm} update -y"
|
||||||
|
|
||||||
|
password = self.credential.password or ""
|
||||||
|
full_cmd = f"echo '{password}' | sudo -S sh -c '{cmd}'"
|
||||||
|
|
||||||
|
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 ausgefuehrt..."
|
||||||
|
await conn.run(f"echo '{password}' | sudo -S reboot", check=False)
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""WinRM executor: run PowerShell on Windows targets via pywinrm."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from satellite.config import Config, Credential
|
||||||
|
|
||||||
|
|
||||||
|
class WinRMError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Target:
|
||||||
|
hostname: str
|
||||||
|
port: int = 5985
|
||||||
|
|
||||||
|
|
||||||
|
class WinRMExecutor:
|
||||||
|
def __init__(self, config: Config, target: Target, credential: Credential) -> None:
|
||||||
|
self.config = config
|
||||||
|
self.target = target
|
||||||
|
self.credential = credential
|
||||||
|
|
||||||
|
def _build_session(self): # type: ignore[no-untyped-def]
|
||||||
|
import winrm # pywinrm
|
||||||
|
|
||||||
|
scheme = "https" if self.target.port == 5986 else "http"
|
||||||
|
endpoint = f"{scheme}://{self.target.hostname}:{self.target.port}/wsman"
|
||||||
|
return winrm.Session(
|
||||||
|
endpoint,
|
||||||
|
auth=(self.credential.username, self.credential.password),
|
||||||
|
transport=self.credential.transport or self.config.winrm_transport,
|
||||||
|
server_cert_validation=self.config.winrm_cert_validation,
|
||||||
|
operation_timeout_sec=60,
|
||||||
|
read_timeout_sec=3600, # Windows Update kann lange laufen
|
||||||
|
)
|
||||||
|
|
||||||
|
async def run_powershell(self, script: str) -> str:
|
||||||
|
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]:
|
||||||
|
output = await self.run_powershell(script)
|
||||||
|
for line in output.splitlines():
|
||||||
|
yield line
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
async def test_connection(self) -> tuple[bool, str]:
|
||||||
|
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
|
||||||
|
return False, str(exc)
|
||||||
|
|
||||||
|
return await asyncio.to_thread(_run)
|
||||||
|
|
||||||
|
async def install_updates(self, reboot_if_required: bool = False) -> AsyncIterator[str]:
|
||||||
|
reboot_block = (
|
||||||
|
"if ($installResult.RebootRequired) { Write-Output 'REBOOT erforderlich - wird ausgefuehrt'; Restart-Computer -Force }"
|
||||||
|
if reboot_if_required
|
||||||
|
else "Write-Output \"RebootRequired: $($installResult.RebootRequired)\""
|
||||||
|
)
|
||||||
|
script = f"""
|
||||||
|
$session = New-Object -ComObject Microsoft.Update.Session
|
||||||
|
$searcher = $session.CreateUpdateSearcher()
|
||||||
|
$result = $searcher.Search("IsInstalled=0")
|
||||||
|
Write-Output "Gefundene Updates: $($result.Updates.Count)"
|
||||||
|
if ($result.Updates.Count -gt 0) {{
|
||||||
|
$toInstall = New-Object -ComObject Microsoft.Update.UpdateColl
|
||||||
|
$result.Updates | ForEach-Object {{
|
||||||
|
Write-Output " - $($_.Title)"
|
||||||
|
$toInstall.Add($_) | Out-Null
|
||||||
|
}}
|
||||||
|
$installer = $session.CreateUpdateInstaller()
|
||||||
|
$installer.Updates = $toInstall
|
||||||
|
$installResult = $installer.Install()
|
||||||
|
Write-Output "ResultCode: $($installResult.ResultCode)"
|
||||||
|
{reboot_block}
|
||||||
|
}}
|
||||||
|
"""
|
||||||
|
async for line in self.stream_powershell(script):
|
||||||
|
yield line
|
||||||
Reference in New Issue
Block a user