feat(system): manage compute contour network profile

This commit is contained in:
DCCONSTRUCTIONS
2026-07-29 12:32:41 +03:00
parent 1eba7c82ba
commit d8ccc9345a
8 changed files with 1139 additions and 9 deletions
+106 -2
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import ipaddress
import json
import os
import re
@@ -14,12 +15,25 @@ from typing import Final, Literal
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, ConfigDict, Field, field_validator
from k1link.web.compute_contour_network import (
ComputeContourNetworkTarget,
NetworkOperationError,
apply_broker_network,
apply_worker_network,
probe_compute_contour_network,
)
CONTOUR_SCHEMA: Final = "missioncore.compute-contour/v1"
CATALOG_SCHEMA: Final = "missioncore.compute-contour-catalog/v1"
INSTALL_SCHEMA: Final = "missioncore.compute-contour-agent-install/v1"
CATALOG_FILE_NAME: Final = "compute-contours.json"
SAFE_IDENTIFIER: Final = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$")
SAFE_NODE_ID: Final = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9_.-]{0,126}[A-Za-z0-9])?$")
SAFE_HOSTNAME: Final = re.compile(
r"^(?=.{1,253}\.?$)(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}"
r"[A-Za-z0-9])?\.)*[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}"
r"[A-Za-z0-9])?\.?$"
)
RootProvider = Callable[[], Path]
TelemetryMode = Literal["agent-mqtt", "legacy-ssh"]
@@ -46,6 +60,7 @@ class ComputeContour(StrictModel):
ssh_port: int = Field(default=22, ge=1, le=65535)
mqtt_host: str = Field(default="127.0.0.1", min_length=1, max_length=253)
mqtt_port: int = Field(default=1883, ge=1, le=65535)
mqtt_bind_address: str = Field(default="127.0.0.1", min_length=1, max_length=45)
telemetry_poll_interval_seconds: int = Field(default=3, ge=1, le=60)
mqtt_publish_interval_seconds: int = Field(default=2, ge=1, le=60)
revision: int = Field(default=0, ge=0)
@@ -75,14 +90,43 @@ class ComputeContour(StrictModel):
raise ValueError("node id contains unsupported characters")
return normalized
@field_validator("address", "mqtt_host")
@field_validator("address")
@classmethod
def validate_host(cls, value: str) -> str:
normalized = value.strip()
if not normalized:
return ""
if any(character.isspace() or ord(character) < 32 for character in normalized):
raise ValueError("host contains whitespace or control characters")
try:
ipaddress.ip_address(normalized)
except ValueError:
if SAFE_HOSTNAME.fullmatch(normalized) is None:
raise ValueError("host must be an IP address or hostname") from None
return normalized.rstrip(".")
@field_validator("mqtt_host")
@classmethod
def validate_mqtt_host(cls, value: str) -> str:
normalized = cls.validate_host(value)
if not normalized:
raise ValueError("MQTT host is required")
return normalized
@field_validator("mqtt_bind_address")
@classmethod
def validate_bind_address(cls, value: str) -> str:
normalized = value.strip()
try:
address = ipaddress.ip_address(normalized)
except ValueError:
raise ValueError("MQTT bind address must be an IP address") from None
if address.is_unspecified or address.is_multicast:
raise ValueError("MQTT bind address must target one local interface")
if not address.is_loopback and not address.is_private:
raise ValueError("MQTT without TLS may bind only to a private LAN address")
return str(address)
class ComputeContourCreate(StrictModel):
display_name: str = Field(min_length=1, max_length=80)
@@ -92,6 +136,7 @@ class ComputeContourCreate(StrictModel):
ssh_port: int = Field(default=22, ge=1, le=65535)
mqtt_host: str = Field(default="127.0.0.1", min_length=1, max_length=253)
mqtt_port: int = Field(default=1883, ge=1, le=65535)
mqtt_bind_address: str = Field(default="127.0.0.1", min_length=1, max_length=45)
telemetry_poll_interval_seconds: int = Field(default=3, ge=1, le=60)
mqtt_publish_interval_seconds: int = Field(default=2, ge=1, le=60)
@@ -105,11 +150,21 @@ class ComputeContourCreate(StrictModel):
def validate_node_id(cls, value: str) -> str:
return ComputeContour.validate_node_id(value)
@field_validator("address", "mqtt_host")
@field_validator("address")
@classmethod
def validate_host(cls, value: str) -> str:
return ComputeContour.validate_host(value)
@field_validator("mqtt_host")
@classmethod
def validate_mqtt_host(cls, value: str) -> str:
return ComputeContour.validate_mqtt_host(value)
@field_validator("mqtt_bind_address")
@classmethod
def validate_bind_address(cls, value: str) -> str:
return ComputeContour.validate_bind_address(value)
class ComputeContourPut(ComputeContourCreate):
revision: int = Field(ge=0)
@@ -128,6 +183,7 @@ def default_compute_contour() -> ComputeContour:
ssh_port=22,
mqtt_host="127.0.0.1",
mqtt_port=1883,
mqtt_bind_address="127.0.0.1",
telemetry_poll_interval_seconds=3,
mqtt_publish_interval_seconds=2,
)
@@ -158,6 +214,7 @@ class ComputeContourStore:
ssh_port=request.ssh_port,
mqtt_host=request.mqtt_host,
mqtt_port=request.mqtt_port,
mqtt_bind_address=request.mqtt_bind_address,
telemetry_poll_interval_seconds=(
request.telemetry_poll_interval_seconds
),
@@ -189,6 +246,7 @@ class ComputeContourStore:
"ssh_port": request.ssh_port,
"mqtt_host": request.mqtt_host,
"mqtt_port": request.mqtt_port,
"mqtt_bind_address": request.mqtt_bind_address,
"telemetry_poll_interval_seconds": (
request.telemetry_poll_interval_seconds
),
@@ -305,6 +363,20 @@ def _agent_install_document(contour: ComputeContour) -> dict[str, object]:
}
def _network_target(contour: ComputeContour) -> ComputeContourNetworkTarget:
return ComputeContourNetworkTarget(
contour_id=contour.contour_id,
expected_node_id=contour.expected_node_id,
platform=contour.platform,
worker_address=contour.address,
ssh_port=contour.ssh_port,
mqtt_host=contour.mqtt_host,
mqtt_port=contour.mqtt_port,
mqtt_bind_address=contour.mqtt_bind_address,
mqtt_publish_interval_seconds=contour.mqtt_publish_interval_seconds,
)
def build_compute_contour_router(*, root_provider: RootProvider) -> APIRouter:
store = ComputeContourStore(root_provider())
router = APIRouter(prefix="/api/v1/system", tags=["system"])
@@ -339,4 +411,36 @@ def build_compute_contour_router(*, root_provider: RootProvider) -> APIRouter:
except KeyError as exc:
raise HTTPException(status_code=404, detail="Контур не найден.") from exc
@router.get("/contours/{contour_id}/network")
def get_contour_network(contour_id: str) -> dict[str, object]:
try:
return probe_compute_contour_network(_network_target(store.get(contour_id)))
except KeyError as exc:
raise HTTPException(status_code=404, detail="Контур не найден.") from exc
@router.post("/contours/{contour_id}/network/broker")
def apply_contour_broker_network(contour_id: str) -> dict[str, object]:
try:
contour = store.get(contour_id)
telemetry_plane_root = (
Path(__file__).resolve().parents[3] / "deploy" / "telemetry-plane"
)
return apply_broker_network(
_network_target(contour),
telemetry_plane_root,
)
except KeyError as exc:
raise HTTPException(status_code=404, detail="Контур не найден.") from exc
except NetworkOperationError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
@router.post("/contours/{contour_id}/network/worker")
def apply_contour_worker_network(contour_id: str) -> dict[str, object]:
try:
return apply_worker_network(_network_target(store.get(contour_id)))
except KeyError as exc:
raise HTTPException(status_code=404, detail="Контур не найден.") from exc
except NetworkOperationError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
return router
+601
View File
@@ -0,0 +1,601 @@
from __future__ import annotations
import base64
import contextlib
import ipaddress
import json
import os
import socket
import stat
import subprocess
import tempfile
import time
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import Any, Final
NETWORK_STATUS_SCHEMA: Final = "missioncore.compute-contour-network-status/v1"
NETWORK_APPLY_SCHEMA: Final = "missioncore.compute-contour-network-apply/v1"
SSH_HOST_ALIAS: Final = "mission-gpu"
def _utc_now() -> str:
return datetime.now(UTC).isoformat().replace("+00:00", "Z")
@dataclass(frozen=True)
class ComputeContourNetworkTarget:
contour_id: str
expected_node_id: str
platform: str
worker_address: str
ssh_port: int
mqtt_host: str
mqtt_port: int
mqtt_bind_address: str
mqtt_publish_interval_seconds: int
class NetworkOperationError(RuntimeError):
pass
def _resolve_addresses(host: str, port: int) -> list[str]:
try:
rows = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
except OSError:
return []
return sorted({str(row[4][0]) for row in rows if row[4]})
def _preferred_lan_addresses(addresses: list[str]) -> list[str]:
lan = [
value
for value in addresses
if not (address := ipaddress.ip_address(value)).is_loopback
and not address.is_link_local
]
return lan or addresses
def _tcp_reachable(host: str, port: int, *, timeout: float = 2.0) -> bool:
try:
rows = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
except OSError:
return False
for family, socket_type, protocol, _canonical_name, address in rows:
connection = socket.socket(family, socket_type, protocol)
try:
connection.settimeout(timeout)
connection.connect(address)
return True
except OSError:
continue
finally:
connection.close()
return False
def _address_belongs_to_host(address: str) -> bool:
family = socket.AF_INET6 if ":" in address else socket.AF_INET
candidate = socket.socket(family, socket.SOCK_STREAM)
try:
candidate.bind((address, 0))
return True
except OSError:
return False
finally:
candidate.close()
def _ssh_arguments(target: ComputeContourNetworkTarget) -> list[str]:
arguments = [
"ssh",
"-o",
"BatchMode=yes",
"-o",
"StrictHostKeyChecking=yes",
"-o",
f"HostKeyAlias={SSH_HOST_ALIAS}",
"-o",
"ConnectTimeout=5",
]
if target.worker_address:
arguments.extend(["-o", f"HostName={target.worker_address}"])
arguments.extend(
[
"-p",
str(target.ssh_port),
SSH_HOST_ALIAS,
"powershell.exe",
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-Command",
(
"$encoded=[Console]::In.ReadToEnd();"
"Invoke-Expression "
"([Text.Encoding]::Unicode.GetString("
"[Convert]::FromBase64String($encoded)))"
),
]
)
return arguments
def _run_worker_powershell(
target: ComputeContourNetworkTarget,
script: str,
*,
timeout: float,
) -> dict[str, Any]:
try:
completed = subprocess.run(
_ssh_arguments(target),
check=False,
capture_output=True,
input=base64.b64encode(script.encode("utf-16le")),
timeout=timeout,
)
except (OSError, subprocess.TimeoutExpired) as exc:
raise NetworkOperationError("Worker недоступен по доверенному SSH-профилю.") from exc
if completed.returncode != 0:
raise NetworkOperationError(
"Worker отклонил сетевую операцию; конфигурация не подтверждена."
)
for encoding in ("utf-8-sig", "cp866"):
try:
document = json.loads(completed.stdout.decode(encoding).strip())
except (UnicodeDecodeError, json.JSONDecodeError):
continue
if isinstance(document, dict):
return document
raise NetworkOperationError("Worker вернул некорректный ответ сетевой проверки.")
WORKER_NETWORK_STATUS_POWERSHELL: Final = r"""
$ErrorActionPreference = "Stop"
$serviceRegistryPath = "HKLM:\SYSTEM\CurrentControlSet\Services\telegraf"
$environmentRows = @((Get-ItemProperty -Path $serviceRegistryPath).Environment)
$environment = @{}
foreach ($entry in $environmentRows) {
$name, $value = $entry -split "=", 2
if ($name) {
$environment[$name.ToUpperInvariant()] = $value
}
}
$mqttHost = [string]$environment["MISSIONCORE_MQTT_HOST"]
$mqttPort = 0
[void][int]::TryParse(
[string]$environment["MISSIONCORE_MQTT_PORT"],
[ref]$mqttPort
)
$resolved = @()
$brokerReachable = $false
if ($mqttHost -and $mqttPort -gt 0) {
try {
$resolved = @(
[Net.Dns]::GetHostAddresses($mqttHost) |
ForEach-Object { $_.IPAddressToString } |
Sort-Object -Unique
)
$client = [Net.Sockets.TcpClient]::new()
try {
$task = $client.ConnectAsync($mqttHost, $mqttPort)
$brokerReachable = $task.Wait(3000) -and $client.Connected
}
finally {
$client.Dispose()
}
}
catch {}
}
[ordered]@{
schema_version = "missioncore.compute-contour-worker-network/v1"
node_id = $env:COMPUTERNAME
service_status = (Get-Service -Name "telegraf").Status.ToString()
mqtt_host = $mqttHost
mqtt_port = $mqttPort
resolved_addresses = $resolved
broker_reachable = $brokerReachable
} | ConvertTo-Json -Depth 5 -Compress
""".strip()
WORKER_NETWORK_APPLY_POWERSHELL: Final = r"""
$ErrorActionPreference = "Stop"
function Test-BrokerEndpoint {
param(
[Parameter(Mandatory = $true)][string]$HostName,
[Parameter(Mandatory = $true)][int]$Port,
[int]$Attempts = 3
)
for ($attempt = 1; $attempt -le $Attempts; $attempt++) {
$client = [Net.Sockets.TcpClient]::new()
try {
[void][Net.Dns]::GetHostAddresses($HostName)
$task = $client.ConnectAsync($HostName, $Port)
if ($task.Wait(3000) -and $client.Connected) {
return $true
}
}
catch {}
finally {
$client.Dispose()
}
if ($attempt -lt $Attempts) {
Start-Sleep -Seconds 1
}
}
return $false
}
$payloadJson = [Text.Encoding]::UTF8.GetString(
[Convert]::FromBase64String("__PAYLOAD_BASE64__")
)
$payload = $payloadJson | ConvertFrom-Json
if ($env:COMPUTERNAME -ne $payload.expected_node_id) {
throw "Worker identity mismatch"
}
if (-not (Test-BrokerEndpoint `
-HostName ([string]$payload.mqtt_host) `
-Port ([int]$payload.mqtt_port) `
-Attempts 3)) {
throw "Broker preflight failed"
}
$serviceRegistryPath = "HKLM:\SYSTEM\CurrentControlSet\Services\telegraf"
$environmentBefore = @((Get-ItemProperty -Path $serviceRegistryPath).Environment)
$replacement = @{
"MISSIONCORE_MQTT_HOST" = [string]$payload.mqtt_host
"MISSIONCORE_MQTT_PORT" = [string]$payload.mqtt_port
"MISSIONCORE_TELEMETRY_INTERVAL" = (
[string]$payload.telemetry_interval_seconds + "s"
)
}
$seen = @{}
$current = @{}
$environmentCandidate = @()
foreach ($entry in $environmentBefore) {
$name, $value = $entry -split "=", 2
$canonicalName = $name.ToUpperInvariant()
if ($name) {
$current[$canonicalName] = $value
}
if ($replacement.ContainsKey($canonicalName)) {
$environmentCandidate += "$canonicalName=$($replacement[$canonicalName])"
$seen[$canonicalName] = $true
}
else {
$environmentCandidate += $entry
}
}
foreach ($name in $replacement.Keys) {
if (-not $seen.ContainsKey($name)) {
$environmentCandidate += "$name=$($replacement[$name])"
}
}
$changed = (
[string]$current["MISSIONCORE_MQTT_HOST"] -ne [string]$payload.mqtt_host -or
[string]$current["MISSIONCORE_MQTT_PORT"] -ne [string]$payload.mqtt_port -or
[string]$current["MISSIONCORE_TELEMETRY_INTERVAL"] -ne (
[string]$payload.telemetry_interval_seconds + "s"
)
)
if ($changed) {
try {
Set-ItemProperty -Path $serviceRegistryPath -Name Environment `
-Type MultiString -Value $environmentCandidate
Restart-Service -Name "telegraf" -Force
$service = Get-Service -Name "telegraf"
$service.WaitForStatus(
[ServiceProcess.ServiceControllerStatus]::Running,
[TimeSpan]::FromSeconds(20)
)
if (-not (Test-BrokerEndpoint `
-HostName ([string]$payload.mqtt_host) `
-Port ([int]$payload.mqtt_port) `
-Attempts 5)) {
throw "Broker verification failed"
}
}
catch {
Set-ItemProperty -Path $serviceRegistryPath -Name Environment `
-Type MultiString -Value $environmentBefore
Restart-Service -Name "telegraf" -Force -ErrorAction SilentlyContinue
throw
}
}
elseif ((Get-Service -Name "telegraf").Status -ne "Running") {
Start-Service -Name "telegraf"
$service = Get-Service -Name "telegraf"
$service.WaitForStatus(
[ServiceProcess.ServiceControllerStatus]::Running,
[TimeSpan]::FromSeconds(20)
)
}
$resolved = @(
[Net.Dns]::GetHostAddresses([string]$payload.mqtt_host) |
ForEach-Object { $_.IPAddressToString } |
Sort-Object -Unique
)
[ordered]@{
schema_version = "missioncore.compute-contour-worker-network/v1"
node_id = $env:COMPUTERNAME
service_status = (Get-Service -Name "telegraf").Status.ToString()
mqtt_host = [string]$payload.mqtt_host
mqtt_port = [int]$payload.mqtt_port
resolved_addresses = $resolved
broker_reachable = $true
changed = $changed
} | ConvertTo-Json -Depth 5 -Compress
""".strip()
def _worker_status(target: ComputeContourNetworkTarget) -> dict[str, Any]:
if target.platform != "windows":
return {
"checked": False,
"reachable": False,
"identity_matches": False,
"node_id": None,
"service_status": None,
"mqtt_host": None,
"mqtt_port": None,
"resolved_addresses": [],
"broker_reachable": False,
"matches_profile": False,
"error_code": "unsupported-platform",
}
try:
document = _run_worker_powershell(
target,
WORKER_NETWORK_STATUS_POWERSHELL,
timeout=10,
)
except NetworkOperationError:
return {
"checked": True,
"reachable": False,
"identity_matches": False,
"node_id": None,
"service_status": None,
"mqtt_host": None,
"mqtt_port": None,
"resolved_addresses": [],
"broker_reachable": False,
"matches_profile": False,
"error_code": "worker-unreachable",
}
node_id = document.get("node_id")
mqtt_host = document.get("mqtt_host")
mqtt_port = document.get("mqtt_port")
identity_matches = node_id == target.expected_node_id
matches_profile = (
identity_matches
and mqtt_host == target.mqtt_host
and mqtt_port == target.mqtt_port
)
return {
"checked": True,
"reachable": True,
"identity_matches": identity_matches,
"node_id": node_id if isinstance(node_id, str) else None,
"service_status": (
document.get("service_status")
if isinstance(document.get("service_status"), str)
else None
),
"mqtt_host": mqtt_host if isinstance(mqtt_host, str) else None,
"mqtt_port": mqtt_port if isinstance(mqtt_port, int) else None,
"resolved_addresses": (
document.get("resolved_addresses")
if isinstance(document.get("resolved_addresses"), list)
else []
),
"broker_reachable": document.get("broker_reachable") is True,
"matches_profile": matches_profile,
"error_code": None if identity_matches else "worker-identity-mismatch",
}
def probe_compute_contour_network(
target: ComputeContourNetworkTarget,
) -> dict[str, Any]:
started = time.perf_counter()
resolved_addresses = _resolve_addresses(target.mqtt_host, target.mqtt_port)
endpoint_reachable = _tcp_reachable(target.mqtt_host, target.mqtt_port)
bind_reachable = _tcp_reachable(
target.mqtt_bind_address,
target.mqtt_port,
)
return {
"schema_version": NETWORK_STATUS_SCHEMA,
"contour_id": target.contour_id,
"observed_at_utc": _utc_now(),
"latency_ms": (time.perf_counter() - started) * 1000,
"broker": {
"configured_host": target.mqtt_host,
"configured_port": target.mqtt_port,
"bind_address": target.mqtt_bind_address,
"resolved_addresses": _preferred_lan_addresses(resolved_addresses),
"endpoint_reachable": endpoint_reachable,
"bind_reachable": bind_reachable,
"bind_address_owned": _address_belongs_to_host(
target.mqtt_bind_address
),
"ready": endpoint_reachable and bind_reachable,
},
"worker": _worker_status(target),
}
def _replace_environment_value(document: str, name: str, value: str) -> str:
lines = document.splitlines()
replacement = f"{name}={value}"
replaced = False
output: list[str] = []
for line in lines:
if line.startswith(f"{name}="):
output.append(replacement)
replaced = True
else:
output.append(line)
if not replaced:
output.append(replacement)
return "\n".join(output) + "\n"
def _atomic_private_write(path: Path, document: str, *, mode: int) -> None:
descriptor, temporary_name = tempfile.mkstemp(
prefix=f".{path.name}.",
suffix=".tmp",
dir=path.parent,
)
temporary = Path(temporary_name)
try:
with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as stream:
stream.write(document)
stream.flush()
os.fsync(stream.fileno())
os.chmod(temporary, stat.S_IMODE(mode))
os.replace(temporary, path)
finally:
temporary.unlink(missing_ok=True)
def _run_compose_broker(telemetry_plane_root: Path) -> None:
environment_path = telemetry_plane_root / ".env"
compose_path = telemetry_plane_root / "compose.yaml"
common = [
"docker",
"compose",
"--env-file",
str(environment_path),
"-f",
str(compose_path),
]
for suffix, timeout in (
(["config", "--quiet"], 20),
(["up", "-d", "--no-build", "broker"], 45),
):
try:
completed = subprocess.run(
[*common, *suffix],
cwd=telemetry_plane_root,
check=False,
capture_output=True,
timeout=timeout,
)
except (OSError, subprocess.TimeoutExpired) as exc:
raise NetworkOperationError(
"Docker не подтвердил конфигурацию MQTT broker."
) from exc
if completed.returncode != 0:
raise NetworkOperationError(
"Docker не подтвердил конфигурацию MQTT broker."
)
def apply_broker_network(
target: ComputeContourNetworkTarget,
telemetry_plane_root: Path,
) -> dict[str, Any]:
if not _address_belongs_to_host(target.mqtt_bind_address):
raise NetworkOperationError(
"Адрес публикации MQTT не принадлежит текущему Mac."
)
environment_path = telemetry_plane_root / ".env"
compose_path = telemetry_plane_root / "compose.yaml"
if not environment_path.is_file() or not compose_path.is_file():
raise NetworkOperationError("Локальный telemetry plane не подготовлен.")
before = environment_path.read_text(encoding="utf-8")
after = _replace_environment_value(
before,
"MISSIONCORE_MQTT_BIND_ADDRESS",
target.mqtt_bind_address,
)
changed = after != before
mode = environment_path.stat().st_mode
try:
if changed:
_atomic_private_write(environment_path, after, mode=mode)
_run_compose_broker(telemetry_plane_root)
if not _tcp_reachable(
target.mqtt_bind_address,
target.mqtt_port,
timeout=3,
):
raise NetworkOperationError(
"MQTT broker не открыл настроенный адрес публикации."
)
except Exception as exc:
if changed:
_atomic_private_write(environment_path, before, mode=mode)
with contextlib.suppress(NetworkOperationError):
_run_compose_broker(telemetry_plane_root)
if isinstance(exc, NetworkOperationError):
raise
raise NetworkOperationError(
"Не удалось применить адрес публикации MQTT broker."
) from exc
return {
"schema_version": NETWORK_APPLY_SCHEMA,
"contour_id": target.contour_id,
"target": "broker",
"changed": changed,
"applied_at_utc": _utc_now(),
"ready": True,
}
def apply_worker_network(
target: ComputeContourNetworkTarget,
) -> dict[str, Any]:
if target.platform != "windows":
raise NetworkOperationError(
"Автоматическое применение сейчас поддерживает Windows Worker."
)
resolved = _resolve_addresses(target.mqtt_host, target.mqtt_port)
if not resolved or any(
not (
(address := ipaddress.ip_address(value)).is_private
or address.is_loopback
or address.is_link_local
)
for value in resolved
):
raise NetworkOperationError(
"MQTT без TLS разрешён только внутри проверенной локальной сети."
)
payload = {
"expected_node_id": target.expected_node_id,
"mqtt_host": target.mqtt_host,
"mqtt_port": target.mqtt_port,
"telemetry_interval_seconds": target.mqtt_publish_interval_seconds,
}
payload_base64 = base64.b64encode(
json.dumps(payload, separators=(",", ":")).encode("utf-8")
).decode("ascii")
script = WORKER_NETWORK_APPLY_POWERSHELL.replace(
"__PAYLOAD_BASE64__",
payload_base64,
)
document = _run_worker_powershell(target, script, timeout=35)
if (
document.get("node_id") != target.expected_node_id
or document.get("mqtt_host") != target.mqtt_host
or document.get("mqtt_port") != target.mqtt_port
or document.get("service_status") != "Running"
or document.get("broker_reachable") is not True
):
raise NetworkOperationError(
"Worker не подтвердил применённый сетевой профиль."
)
return {
"schema_version": NETWORK_APPLY_SCHEMA,
"contour_id": target.contour_id,
"target": "worker",
"changed": document.get("changed") is True,
"applied_at_utc": _utc_now(),
"ready": True,
}