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
+64
View File
@@ -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)
+19
View File
@@ -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
+20
View File
@@ -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: ""
+32
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
"""Insight Updater Satellite - remote agent for customer networks."""
__version__ = "0.2.0"
+29
View File
@@ -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
+79
View File
@@ -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()
+67
View File
@@ -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,
)
+205
View File
@@ -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()
+72
View File
@@ -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"])
+84
View File
@@ -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)
+96
View File
@@ -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