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:
B0rbor4d
2026-08-07 03:42:06 +00:00
parent b91dd66fee
commit cc4c3fcecb
72 changed files with 2759 additions and 1642 deletions
+4 -2
View File
@@ -1,14 +1,16 @@
"""SQLAlchemy ORM models."""
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.update_job import UpdateJob, UpdateLog
from app.models.user import User
__all__ = [
"AuditLog",
"Credential",
"Customer",
"Satellite",
"Server",
"UpdateJob",
"UpdateLog",
+5 -2
View File
@@ -2,7 +2,7 @@
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 app.core.database import Base
@@ -15,9 +15,12 @@ class AuditLog(Base):
timestamp: Mapped[datetime] = mapped_column(
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)
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
details: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON blob
ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True)
-39
View File
@@ -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),
)
+36
View File
@@ -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"
)
+52
View File
@@ -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"
)
+24 -9
View File
@@ -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
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 app.core.database import Base
class ServerType(str, enum.Enum):
class ServerType(enum.StrEnum):
WINDOWS = "windows" # WinRM
LINUX = "linux" # SSH
CAU_CLUSTER = "cau_cluster" # Cluster-Aware Updating
@@ -17,22 +21,33 @@ class ServerType(str, enum.Enum):
class Server(Base):
__tablename__ = "servers"
__table_args__ = (
UniqueConstraint("customer_id", "name", name="uq_server_customer_name"),
)
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))
port: Mapped[int] = mapped_column(default=5985)
type: Mapped[ServerType] = mapped_column(Enum(ServerType), default=ServerType.WINDOWS)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
tags: Mapped[str | None] = mapped_column(String(500), nullable=True) # comma-separated
credential_id: Mapped[int | None] = mapped_column(
ForeignKey("credentials.id"), nullable=True
)
credential: Mapped["Credential | None"] = relationship(lazy="selectin") # noqa: F821
# Symbolic reference to a credential stored locally on the satellite
credential_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
# Last health result reported by a satellite
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_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(
DateTime(timezone=True), default=lambda: datetime.now(UTC)
@@ -44,5 +59,5 @@ class Server(Base):
)
jobs: Mapped[list["UpdateJob"]] = relationship( # noqa: F821
back_populates="server", cascade="all, delete-orphan"
back_populates="server"
)
+34 -6
View File
@@ -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
from datetime import UTC, datetime
@@ -9,36 +15,58 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class JobStatus(str, enum.Enum):
class JobStatus(enum.StrEnum):
PENDING = "pending"
CLAIMED = "claimed"
RUNNING = "running"
SUCCESS = "success"
FAILED = "failed"
CANCELLED = "cancelled"
class JobType(str, enum.Enum):
class JobType(enum.StrEnum):
WINDOWS_UPDATE = "windows_update"
LINUX_UPDATE = "linux_update"
CAU_RUN = "cau_run"
HEALTH_CHECK = "health_check"
NETWORK_SCAN = "network_scan"
class UpdateJob(Base):
__tablename__ = "update_jobs"
id: Mapped[int] = mapped_column(primary_key=True)
server_id: Mapped[int] = mapped_column(ForeignKey("servers.id"), index=True)
server: Mapped["Server"] = relationship(back_populates="jobs", lazy="selectin") # noqa: F821
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))
status: Mapped[JobStatus] = mapped_column(Enum(JobStatus), default=JobStatus.PENDING, index=True)
progress_percent: Mapped[int] = mapped_column(Integer, default=0)
current_phase: Mapped[str | None] = mapped_column(String(255), nullable=True)
started_by: Mapped[str] = mapped_column(String(255)) # username
# 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)
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)
created_at: Mapped[datetime] = mapped_column(