feat(system): manage compute contour network profile
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user