feat(system): manage compute contour network profile
This commit is contained in:
@@ -12,10 +12,13 @@ import {
|
|||||||
} from "@nodedc/ui-react";
|
} from "@nodedc/ui-react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
applyComputeContourNetwork,
|
||||||
fetchComputeContourAgentInstall,
|
fetchComputeContourAgentInstall,
|
||||||
|
fetchComputeContourNetwork,
|
||||||
type ComputeContour,
|
type ComputeContour,
|
||||||
type ComputeContourAgentInstall,
|
type ComputeContourAgentInstall,
|
||||||
type ComputeContourDraft,
|
type ComputeContourDraft,
|
||||||
|
type ComputeContourNetworkStatus,
|
||||||
type ComputeContourPlatform,
|
type ComputeContourPlatform,
|
||||||
} from "../../core/system/computeContours";
|
} from "../../core/system/computeContours";
|
||||||
import {
|
import {
|
||||||
@@ -56,6 +59,7 @@ function emptyDraft(): ComputeContourDraft {
|
|||||||
ssh_port: 22,
|
ssh_port: 22,
|
||||||
mqtt_host: "127.0.0.1",
|
mqtt_host: "127.0.0.1",
|
||||||
mqtt_port: 1883,
|
mqtt_port: 1883,
|
||||||
|
mqtt_bind_address: "127.0.0.1",
|
||||||
telemetry_poll_interval_seconds: DEFAULT_TELEMETRY_POLL_INTERVAL_SECONDS,
|
telemetry_poll_interval_seconds: DEFAULT_TELEMETRY_POLL_INTERVAL_SECONDS,
|
||||||
mqtt_publish_interval_seconds: 2,
|
mqtt_publish_interval_seconds: 2,
|
||||||
};
|
};
|
||||||
@@ -72,6 +76,7 @@ function draftFromContour(contour: ComputeContour | null): ComputeContourDraft {
|
|||||||
ssh_port: contour.ssh_port,
|
ssh_port: contour.ssh_port,
|
||||||
mqtt_host: contour.mqtt_host,
|
mqtt_host: contour.mqtt_host,
|
||||||
mqtt_port: contour.mqtt_port,
|
mqtt_port: contour.mqtt_port,
|
||||||
|
mqtt_bind_address: contour.mqtt_bind_address,
|
||||||
telemetry_poll_interval_seconds: contour.telemetry_poll_interval_seconds,
|
telemetry_poll_interval_seconds: contour.telemetry_poll_interval_seconds,
|
||||||
mqtt_publish_interval_seconds: contour.mqtt_publish_interval_seconds,
|
mqtt_publish_interval_seconds: contour.mqtt_publish_interval_seconds,
|
||||||
};
|
};
|
||||||
@@ -94,6 +99,10 @@ export function ComputeContourSettingsWindow({
|
|||||||
);
|
);
|
||||||
const [activeSection, setActiveSection] = useState<"connection" | "agent">("connection");
|
const [activeSection, setActiveSection] = useState<"connection" | "agent">("connection");
|
||||||
const [install, setInstall] = useState<ComputeContourAgentInstall | null>(null);
|
const [install, setInstall] = useState<ComputeContourAgentInstall | null>(null);
|
||||||
|
const [network, setNetwork] = useState<ComputeContourNetworkStatus | null>(null);
|
||||||
|
const [networkAction, setNetworkAction] = useState<
|
||||||
|
"refresh" | "broker" | "worker" | null
|
||||||
|
>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -105,9 +114,30 @@ export function ComputeContourSettingsWindow({
|
|||||||
setMqttPublishIntervalDraft(String(nextDraft.mqtt_publish_interval_seconds));
|
setMqttPublishIntervalDraft(String(nextDraft.mqtt_publish_interval_seconds));
|
||||||
setActiveSection("connection");
|
setActiveSection("connection");
|
||||||
setInstall(null);
|
setInstall(null);
|
||||||
|
setNetwork(null);
|
||||||
|
setNetworkAction(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
}, [contour, mode, open]);
|
}, [contour, mode, open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || mode !== "edit" || !contour) return;
|
||||||
|
const controller = new AbortController();
|
||||||
|
setNetworkAction("refresh");
|
||||||
|
void fetchComputeContourNetwork(contour.contour_id, controller.signal)
|
||||||
|
.then((document) => {
|
||||||
|
if (!controller.signal.aborted) setNetwork(document);
|
||||||
|
})
|
||||||
|
.catch((reason: unknown) => {
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
setError(reason instanceof Error ? reason.message : "Сетевая проверка недоступна.");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!controller.signal.aborted) setNetworkAction(null);
|
||||||
|
});
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [contour, mode, open]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || mode !== "edit" || !contour) return;
|
if (!open || mode !== "edit" || !contour) return;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
@@ -135,6 +165,8 @@ export function ComputeContourSettingsWindow({
|
|||||||
const valid = useMemo(() => (
|
const valid = useMemo(() => (
|
||||||
Boolean(draft.display_name.trim())
|
Boolean(draft.display_name.trim())
|
||||||
&& Boolean(draft.expected_node_id.trim())
|
&& Boolean(draft.expected_node_id.trim())
|
||||||
|
&& Boolean(draft.mqtt_host.trim())
|
||||||
|
&& Boolean(draft.mqtt_bind_address.trim())
|
||||||
&& Number.isInteger(draft.ssh_port)
|
&& Number.isInteger(draft.ssh_port)
|
||||||
&& draft.ssh_port > 0
|
&& draft.ssh_port > 0
|
||||||
&& Number.isInteger(draft.mqtt_port)
|
&& Number.isInteger(draft.mqtt_port)
|
||||||
@@ -143,6 +175,16 @@ export function ComputeContourSettingsWindow({
|
|||||||
&& mqttPublishIntervalSeconds !== null
|
&& mqttPublishIntervalSeconds !== null
|
||||||
), [draft, mqttPublishIntervalSeconds, telemetryPollIntervalSeconds]);
|
), [draft, mqttPublishIntervalSeconds, telemetryPollIntervalSeconds]);
|
||||||
|
|
||||||
|
const networkProfileDirty = Boolean(
|
||||||
|
contour
|
||||||
|
&& (
|
||||||
|
draft.mqtt_host !== contour.mqtt_host
|
||||||
|
|| draft.mqtt_port !== contour.mqtt_port
|
||||||
|
|| draft.mqtt_bind_address !== contour.mqtt_bind_address
|
||||||
|
|| draft.mqtt_publish_interval_seconds !== contour.mqtt_publish_interval_seconds
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
const commitTelemetryPollInterval = () => {
|
const commitTelemetryPollInterval = () => {
|
||||||
const resolution = resolveTelemetryPollIntervalDraft(
|
const resolution = resolveTelemetryPollIntervalDraft(
|
||||||
telemetryPollIntervalDraft,
|
telemetryPollIntervalDraft,
|
||||||
@@ -205,6 +247,35 @@ export function ComputeContourSettingsWindow({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const refreshNetwork = async () => {
|
||||||
|
if (!contour || networkAction) return;
|
||||||
|
setNetworkAction("refresh");
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
setNetwork(await fetchComputeContourNetwork(contour.contour_id));
|
||||||
|
} catch (reason) {
|
||||||
|
setError(reason instanceof Error ? reason.message : "Сетевая проверка недоступна.");
|
||||||
|
} finally {
|
||||||
|
setNetworkAction(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyNetwork = async (target: "broker" | "worker") => {
|
||||||
|
if (!contour || networkAction || networkProfileDirty) return;
|
||||||
|
setNetworkAction(target);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await applyComputeContourNetwork(contour.contour_id, target);
|
||||||
|
setNetwork(await fetchComputeContourNetwork(contour.contour_id));
|
||||||
|
} catch (reason) {
|
||||||
|
setError(
|
||||||
|
reason instanceof Error ? reason.message : "Сетевой профиль не применён.",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setNetworkAction(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FeatureSettingsWindow
|
<FeatureSettingsWindow
|
||||||
open={open}
|
open={open}
|
||||||
@@ -244,6 +315,7 @@ export function ComputeContourSettingsWindow({
|
|||||||
>
|
>
|
||||||
<div className="compute-contour-settings">
|
<div className="compute-contour-settings">
|
||||||
{activeSection === "connection" ? (
|
{activeSection === "connection" ? (
|
||||||
|
<>
|
||||||
<SettingsCard
|
<SettingsCard
|
||||||
eyebrow="КОНТУР"
|
eyebrow="КОНТУР"
|
||||||
title="Идентичность и локальная сеть"
|
title="Идентичность и локальная сеть"
|
||||||
@@ -289,6 +361,7 @@ export function ComputeContourSettingsWindow({
|
|||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label="MQTT broker"
|
label="MQTT broker"
|
||||||
|
hint="Стабильный hostname рекомендуется вместо выдаваемого DHCP IP"
|
||||||
value={draft.mqtt_host}
|
value={draft.mqtt_host}
|
||||||
onChange={(event) => setDraft((current) => ({
|
onChange={(event) => setDraft((current) => ({
|
||||||
...current,
|
...current,
|
||||||
@@ -306,6 +379,15 @@ export function ComputeContourSettingsWindow({
|
|||||||
mqtt_port: Number(event.currentTarget.value),
|
mqtt_port: Number(event.currentTarget.value),
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Публикация broker"
|
||||||
|
hint="IP текущего Mac в разрешённой LAN · 0.0.0.0 запрещён"
|
||||||
|
value={draft.mqtt_bind_address}
|
||||||
|
onChange={(event) => setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
mqtt_bind_address: event.currentTarget.value,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label="Обновление интерфейса"
|
label="Обновление интерфейса"
|
||||||
hint="1–60 с"
|
hint="1–60 с"
|
||||||
@@ -369,6 +451,90 @@ export function ComputeContourSettingsWindow({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
|
<SettingsCard
|
||||||
|
eyebrow="СЕТЕВОЙ ПРОФИЛЬ"
|
||||||
|
title="MQTT · Mac ↔ Worker"
|
||||||
|
description="Mesh-точки одной LAN не закрепляются по отдельности. Профиль хранит стабильное имя Mac и адрес публикации broker в текущей сети."
|
||||||
|
actions={(
|
||||||
|
<StatusBadge tone={network?.broker.ready ? "success" : "warning"}>
|
||||||
|
{network?.broker.ready ? "Broker доступен" : "Нужна проверка"}
|
||||||
|
</StatusBadge>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="compute-contour-settings__network">
|
||||||
|
<dl>
|
||||||
|
<div>
|
||||||
|
<dt>Endpoint Worker</dt>
|
||||||
|
<dd>{network
|
||||||
|
? `${network.broker.configured_host}:${network.broker.configured_port}`
|
||||||
|
: "—"}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Текущий IP</dt>
|
||||||
|
<dd>{network?.broker.resolved_addresses.join(", ") || "—"}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Публикация Mac</dt>
|
||||||
|
<dd>{network?.broker.bind_address ?? "—"}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Worker 006</dt>
|
||||||
|
<dd>{network?.worker.reachable
|
||||||
|
? `${network.worker.mqtt_host ?? "—"}:${network.worker.mqtt_port ?? "—"}`
|
||||||
|
: "Недоступен"}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<div className="compute-contour-settings__network-statuses">
|
||||||
|
<StatusBadge tone={network?.broker.bind_reachable ? "success" : "danger"}>
|
||||||
|
{network?.broker.bind_reachable
|
||||||
|
? "Listener Mac доступен"
|
||||||
|
: "Listener Mac недоступен"}
|
||||||
|
</StatusBadge>
|
||||||
|
<StatusBadge tone={network?.worker.broker_reachable ? "success" : "danger"}>
|
||||||
|
{network?.worker.broker_reachable
|
||||||
|
? "Worker видит broker"
|
||||||
|
: "Worker не видит broker"}
|
||||||
|
</StatusBadge>
|
||||||
|
<StatusBadge tone={network?.worker.matches_profile ? "success" : "warning"}>
|
||||||
|
{network?.worker.matches_profile
|
||||||
|
? "Профиль синхронизирован"
|
||||||
|
: "Профиль отличается"}
|
||||||
|
</StatusBadge>
|
||||||
|
</div>
|
||||||
|
<div className="compute-contour-settings__network-actions">
|
||||||
|
<Button
|
||||||
|
size="compact"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={Boolean(networkAction)}
|
||||||
|
icon={<Icon name="refresh" size={14} />}
|
||||||
|
onClick={() => void refreshNetwork()}
|
||||||
|
>
|
||||||
|
{networkAction === "refresh" ? "Проверяем…" : "Проверить"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="compact"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={Boolean(networkAction) || networkProfileDirty}
|
||||||
|
onClick={() => void applyNetwork("broker")}
|
||||||
|
>
|
||||||
|
{networkAction === "broker" ? "Применяем…" : "Применить на Mac"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="compact"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={Boolean(networkAction) || networkProfileDirty}
|
||||||
|
onClick={() => void applyNetwork("worker")}
|
||||||
|
>
|
||||||
|
{networkAction === "worker" ? "Применяем…" : "Применить на Worker"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{networkProfileDirty ? (
|
||||||
|
<p>Сначала сохраните изменённый сетевой профиль, затем примените его.</p>
|
||||||
|
) : null}
|
||||||
|
{error ? <StatusBadge tone="danger">{error}</StatusBadge> : null}
|
||||||
|
</div>
|
||||||
|
</SettingsCard>
|
||||||
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{activeSection === "agent" ? (
|
{activeSection === "agent" ? (
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export interface ComputeContour {
|
|||||||
ssh_port: number;
|
ssh_port: number;
|
||||||
mqtt_host: string;
|
mqtt_host: string;
|
||||||
mqtt_port: number;
|
mqtt_port: number;
|
||||||
|
mqtt_bind_address: string;
|
||||||
telemetry_poll_interval_seconds: number;
|
telemetry_poll_interval_seconds: number;
|
||||||
mqtt_publish_interval_seconds: number;
|
mqtt_publish_interval_seconds: number;
|
||||||
revision: number;
|
revision: number;
|
||||||
@@ -33,6 +34,7 @@ export interface ComputeContourDraft {
|
|||||||
ssh_port: number;
|
ssh_port: number;
|
||||||
mqtt_host: string;
|
mqtt_host: string;
|
||||||
mqtt_port: number;
|
mqtt_port: number;
|
||||||
|
mqtt_bind_address: string;
|
||||||
telemetry_poll_interval_seconds: number;
|
telemetry_poll_interval_seconds: number;
|
||||||
mqtt_publish_interval_seconds: number;
|
mqtt_publish_interval_seconds: number;
|
||||||
}
|
}
|
||||||
@@ -52,6 +54,45 @@ export interface ComputeContourAgentInstall {
|
|||||||
blocked_reason: string | null;
|
blocked_reason: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ComputeContourNetworkStatus {
|
||||||
|
schema_version: "missioncore.compute-contour-network-status/v1";
|
||||||
|
contour_id: string;
|
||||||
|
observed_at_utc: string;
|
||||||
|
latency_ms: number;
|
||||||
|
broker: {
|
||||||
|
configured_host: string;
|
||||||
|
configured_port: number;
|
||||||
|
bind_address: string;
|
||||||
|
resolved_addresses: string[];
|
||||||
|
endpoint_reachable: boolean;
|
||||||
|
bind_reachable: boolean;
|
||||||
|
bind_address_owned: boolean;
|
||||||
|
ready: boolean;
|
||||||
|
};
|
||||||
|
worker: {
|
||||||
|
checked: boolean;
|
||||||
|
reachable: boolean;
|
||||||
|
identity_matches: boolean;
|
||||||
|
node_id: string | null;
|
||||||
|
service_status: string | null;
|
||||||
|
mqtt_host: string | null;
|
||||||
|
mqtt_port: number | null;
|
||||||
|
resolved_addresses: string[];
|
||||||
|
broker_reachable: boolean;
|
||||||
|
matches_profile: boolean;
|
||||||
|
error_code: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComputeContourNetworkApply {
|
||||||
|
schema_version: "missioncore.compute-contour-network-apply/v1";
|
||||||
|
contour_id: string;
|
||||||
|
target: "broker" | "worker";
|
||||||
|
changed: boolean;
|
||||||
|
applied_at_utc: string;
|
||||||
|
ready: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||||
}
|
}
|
||||||
@@ -66,7 +107,11 @@ async function requestJson(url: string, init: RequestInit): Promise<unknown> {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Контуры вычисления: HTTP ${response.status}.`);
|
const document = await response.json().catch(() => null);
|
||||||
|
const detail = isRecord(document) && typeof document.detail === "string"
|
||||||
|
? document.detail
|
||||||
|
: null;
|
||||||
|
throw new Error(detail ?? `Контуры вычисления: HTTP ${response.status}.`);
|
||||||
}
|
}
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
@@ -100,6 +145,7 @@ export async function createComputeContour(
|
|||||||
ssh_port: draft.ssh_port,
|
ssh_port: draft.ssh_port,
|
||||||
mqtt_host: draft.mqtt_host,
|
mqtt_host: draft.mqtt_host,
|
||||||
mqtt_port: draft.mqtt_port,
|
mqtt_port: draft.mqtt_port,
|
||||||
|
mqtt_bind_address: draft.mqtt_bind_address,
|
||||||
telemetry_poll_interval_seconds: draft.telemetry_poll_interval_seconds,
|
telemetry_poll_interval_seconds: draft.telemetry_poll_interval_seconds,
|
||||||
mqtt_publish_interval_seconds: draft.mqtt_publish_interval_seconds,
|
mqtt_publish_interval_seconds: draft.mqtt_publish_interval_seconds,
|
||||||
}),
|
}),
|
||||||
@@ -152,3 +198,45 @@ export async function fetchComputeContourAgentInstall(
|
|||||||
}
|
}
|
||||||
return document as unknown as ComputeContourAgentInstall;
|
return document as unknown as ComputeContourAgentInstall;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchComputeContourNetwork(
|
||||||
|
contourId: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<ComputeContourNetworkStatus> {
|
||||||
|
const document = await requestJson(
|
||||||
|
`/api/v1/system/contours/${encodeURIComponent(contourId)}/network`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
signal,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!isRecord(document)
|
||||||
|
|| document.schema_version !== "missioncore.compute-contour-network-status/v1"
|
||||||
|
) {
|
||||||
|
throw new Error("Сетевой статус контура не соответствует контракту.");
|
||||||
|
}
|
||||||
|
return document as unknown as ComputeContourNetworkStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyComputeContourNetwork(
|
||||||
|
contourId: string,
|
||||||
|
target: "broker" | "worker",
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<ComputeContourNetworkApply> {
|
||||||
|
const document = await requestJson(
|
||||||
|
`/api/v1/system/contours/${encodeURIComponent(contourId)}/network/${target}`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
signal,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!isRecord(document)
|
||||||
|
|| document.schema_version !== "missioncore.compute-contour-network-apply/v1"
|
||||||
|
|| document.target !== target
|
||||||
|
) {
|
||||||
|
throw new Error("Результат применения сетевого профиля не соответствует контракту.");
|
||||||
|
}
|
||||||
|
return document as unknown as ComputeContourNetworkApply;
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,19 +16,22 @@
|
|||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.compute-contour-settings__install {
|
.compute-contour-settings__install,
|
||||||
|
.compute-contour-settings__network {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.compute-contour-settings__install dl {
|
.compute-contour-settings__install dl,
|
||||||
|
.compute-contour-settings__network dl {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 0.65rem;
|
gap: 0.65rem;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.compute-contour-settings__install dl > div {
|
.compute-contour-settings__install dl > div,
|
||||||
|
.compute-contour-settings__network dl > div {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.22rem;
|
gap: 0.22rem;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -37,12 +40,14 @@
|
|||||||
background: var(--station-panel-soft);
|
background: var(--station-panel-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.compute-contour-settings__install dt {
|
.compute-contour-settings__install dt,
|
||||||
|
.compute-contour-settings__network dt {
|
||||||
color: var(--nodedc-text-muted);
|
color: var(--nodedc-text-muted);
|
||||||
font-size: 0.58rem;
|
font-size: 0.58rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.compute-contour-settings__install dd {
|
.compute-contour-settings__install dd,
|
||||||
|
.compute-contour-settings__network dd {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--nodedc-text-primary);
|
color: var(--nodedc-text-primary);
|
||||||
@@ -64,6 +69,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.compute-contour-settings__install p,
|
.compute-contour-settings__install p,
|
||||||
|
.compute-contour-settings__network p,
|
||||||
.compute-contour-settings__empty {
|
.compute-contour-settings__empty {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--nodedc-text-muted);
|
color: var(--nodedc-text-muted);
|
||||||
@@ -71,6 +77,14 @@
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.compute-contour-settings__network-statuses,
|
||||||
|
.compute-contour-settings__network-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
.system-workspace__lead,
|
.system-workspace__lead,
|
||||||
.system-section-heading {
|
.system-section-heading {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -626,7 +640,8 @@
|
|||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.compute-contour-settings__form,
|
.compute-contour-settings__form,
|
||||||
.compute-contour-settings__install dl {
|
.compute-contour-settings__install dl,
|
||||||
|
.compute-contour-settings__network dl {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -89,6 +89,10 @@ test("Worker 006 telemetry remains a bounded system feature slice", async () =>
|
|||||||
assert.match(contourSettings, /FieldFrame label="Операционная система"/);
|
assert.match(contourSettings, /FieldFrame label="Операционная система"/);
|
||||||
assert.match(contourSettings, /label="Обновление интерфейса"/);
|
assert.match(contourSettings, /label="Обновление интерфейса"/);
|
||||||
assert.match(contourSettings, /label="Интервал MQTT агента"/);
|
assert.match(contourSettings, /label="Интервал MQTT агента"/);
|
||||||
|
assert.match(contourSettings, /label="Публикация broker"/);
|
||||||
|
assert.match(contourSettings, /MQTT · Mac ↔ Worker/);
|
||||||
|
assert.match(contourSettings, /applyComputeContourNetwork/);
|
||||||
|
assert.match(contourSettings, /Применить на Worker/);
|
||||||
assert.match(contourSettings, /value=\{telemetryPollIntervalDraft\}/);
|
assert.match(contourSettings, /value=\{telemetryPollIntervalDraft\}/);
|
||||||
assert.match(contourSettings, /onBlur=\{commitTelemetryPollInterval\}/);
|
assert.match(contourSettings, /onBlur=\{commitTelemetryPollInterval\}/);
|
||||||
assert.match(contourSettings, /event\.key === "Escape"/);
|
assert.match(contourSettings, /event\.key === "Escape"/);
|
||||||
@@ -98,6 +102,11 @@ test("Worker 006 telemetry remains a bounded system feature slice", async () =>
|
|||||||
);
|
);
|
||||||
assert.match(contourContract, /telemetry_poll_interval_seconds: number/);
|
assert.match(contourContract, /telemetry_poll_interval_seconds: number/);
|
||||||
assert.match(contourContract, /mqtt_publish_interval_seconds: number/);
|
assert.match(contourContract, /mqtt_publish_interval_seconds: number/);
|
||||||
|
assert.match(contourContract, /mqtt_bind_address: string/);
|
||||||
|
assert.match(
|
||||||
|
contourContract,
|
||||||
|
/missioncore\.compute-contour-network-status\/v1/,
|
||||||
|
);
|
||||||
assert.match(pollIntervalContract, /MIN_TELEMETRY_POLL_INTERVAL_SECONDS\s*=\s*1/);
|
assert.match(pollIntervalContract, /MIN_TELEMETRY_POLL_INTERVAL_SECONDS\s*=\s*1/);
|
||||||
assert.match(telemetryPolling, /normalizeWorkerTelemetryPollMilliseconds/);
|
assert.match(telemetryPolling, /normalizeWorkerTelemetryPollMilliseconds/);
|
||||||
assert.match(styles, /system-telemetry\.css/);
|
assert.match(styles, /system-telemetry\.css/);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@@ -14,12 +15,25 @@ from typing import Final, Literal
|
|||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
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"
|
CONTOUR_SCHEMA: Final = "missioncore.compute-contour/v1"
|
||||||
CATALOG_SCHEMA: Final = "missioncore.compute-contour-catalog/v1"
|
CATALOG_SCHEMA: Final = "missioncore.compute-contour-catalog/v1"
|
||||||
INSTALL_SCHEMA: Final = "missioncore.compute-contour-agent-install/v1"
|
INSTALL_SCHEMA: Final = "missioncore.compute-contour-agent-install/v1"
|
||||||
CATALOG_FILE_NAME: Final = "compute-contours.json"
|
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_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_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]
|
RootProvider = Callable[[], Path]
|
||||||
TelemetryMode = Literal["agent-mqtt", "legacy-ssh"]
|
TelemetryMode = Literal["agent-mqtt", "legacy-ssh"]
|
||||||
@@ -46,6 +60,7 @@ class ComputeContour(StrictModel):
|
|||||||
ssh_port: int = Field(default=22, ge=1, le=65535)
|
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_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_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)
|
telemetry_poll_interval_seconds: int = Field(default=3, ge=1, le=60)
|
||||||
mqtt_publish_interval_seconds: int = Field(default=2, ge=1, le=60)
|
mqtt_publish_interval_seconds: int = Field(default=2, ge=1, le=60)
|
||||||
revision: int = Field(default=0, ge=0)
|
revision: int = Field(default=0, ge=0)
|
||||||
@@ -75,14 +90,43 @@ class ComputeContour(StrictModel):
|
|||||||
raise ValueError("node id contains unsupported characters")
|
raise ValueError("node id contains unsupported characters")
|
||||||
return normalized
|
return normalized
|
||||||
|
|
||||||
@field_validator("address", "mqtt_host")
|
@field_validator("address")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_host(cls, value: str) -> str:
|
def validate_host(cls, value: str) -> str:
|
||||||
normalized = value.strip()
|
normalized = value.strip()
|
||||||
|
if not normalized:
|
||||||
|
return ""
|
||||||
if any(character.isspace() or ord(character) < 32 for character in normalized):
|
if any(character.isspace() or ord(character) < 32 for character in normalized):
|
||||||
raise ValueError("host contains whitespace or control characters")
|
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
|
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):
|
class ComputeContourCreate(StrictModel):
|
||||||
display_name: str = Field(min_length=1, max_length=80)
|
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)
|
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_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_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)
|
telemetry_poll_interval_seconds: int = Field(default=3, ge=1, le=60)
|
||||||
mqtt_publish_interval_seconds: int = Field(default=2, 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:
|
def validate_node_id(cls, value: str) -> str:
|
||||||
return ComputeContour.validate_node_id(value)
|
return ComputeContour.validate_node_id(value)
|
||||||
|
|
||||||
@field_validator("address", "mqtt_host")
|
@field_validator("address")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_host(cls, value: str) -> str:
|
def validate_host(cls, value: str) -> str:
|
||||||
return ComputeContour.validate_host(value)
|
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):
|
class ComputeContourPut(ComputeContourCreate):
|
||||||
revision: int = Field(ge=0)
|
revision: int = Field(ge=0)
|
||||||
@@ -128,6 +183,7 @@ def default_compute_contour() -> ComputeContour:
|
|||||||
ssh_port=22,
|
ssh_port=22,
|
||||||
mqtt_host="127.0.0.1",
|
mqtt_host="127.0.0.1",
|
||||||
mqtt_port=1883,
|
mqtt_port=1883,
|
||||||
|
mqtt_bind_address="127.0.0.1",
|
||||||
telemetry_poll_interval_seconds=3,
|
telemetry_poll_interval_seconds=3,
|
||||||
mqtt_publish_interval_seconds=2,
|
mqtt_publish_interval_seconds=2,
|
||||||
)
|
)
|
||||||
@@ -158,6 +214,7 @@ class ComputeContourStore:
|
|||||||
ssh_port=request.ssh_port,
|
ssh_port=request.ssh_port,
|
||||||
mqtt_host=request.mqtt_host,
|
mqtt_host=request.mqtt_host,
|
||||||
mqtt_port=request.mqtt_port,
|
mqtt_port=request.mqtt_port,
|
||||||
|
mqtt_bind_address=request.mqtt_bind_address,
|
||||||
telemetry_poll_interval_seconds=(
|
telemetry_poll_interval_seconds=(
|
||||||
request.telemetry_poll_interval_seconds
|
request.telemetry_poll_interval_seconds
|
||||||
),
|
),
|
||||||
@@ -189,6 +246,7 @@ class ComputeContourStore:
|
|||||||
"ssh_port": request.ssh_port,
|
"ssh_port": request.ssh_port,
|
||||||
"mqtt_host": request.mqtt_host,
|
"mqtt_host": request.mqtt_host,
|
||||||
"mqtt_port": request.mqtt_port,
|
"mqtt_port": request.mqtt_port,
|
||||||
|
"mqtt_bind_address": request.mqtt_bind_address,
|
||||||
"telemetry_poll_interval_seconds": (
|
"telemetry_poll_interval_seconds": (
|
||||||
request.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:
|
def build_compute_contour_router(*, root_provider: RootProvider) -> APIRouter:
|
||||||
store = ComputeContourStore(root_provider())
|
store = ComputeContourStore(root_provider())
|
||||||
router = APIRouter(prefix="/api/v1/system", tags=["system"])
|
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:
|
except KeyError as exc:
|
||||||
raise HTTPException(status_code=404, detail="Контур не найден.") from 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
|
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,
|
||||||
|
}
|
||||||
@@ -41,6 +41,7 @@ def test_contour_store_migrates_worker_006_as_first_configuration(
|
|||||||
assert contours[0].telemetry_mode == "agent-mqtt"
|
assert contours[0].telemetry_mode == "agent-mqtt"
|
||||||
assert contours[0].telemetry_poll_interval_seconds == 3
|
assert contours[0].telemetry_poll_interval_seconds == 3
|
||||||
assert contours[0].mqtt_publish_interval_seconds == 2
|
assert contours[0].mqtt_publish_interval_seconds == 2
|
||||||
|
assert contours[0].mqtt_bind_address == "127.0.0.1"
|
||||||
assert not store.path.exists()
|
assert not store.path.exists()
|
||||||
|
|
||||||
|
|
||||||
@@ -53,6 +54,7 @@ def test_contour_store_creates_and_updates_private_catalog(tmp_path: Path) -> No
|
|||||||
platform="linux",
|
platform="linux",
|
||||||
address="192.0.2.25",
|
address="192.0.2.25",
|
||||||
mqtt_host="192.0.2.5",
|
mqtt_host="192.0.2.5",
|
||||||
|
mqtt_bind_address="192.168.10.5",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
updated = store.update(
|
updated = store.update(
|
||||||
@@ -67,6 +69,7 @@ def test_contour_store_creates_and_updates_private_catalog(tmp_path: Path) -> No
|
|||||||
ssh_port=22,
|
ssh_port=22,
|
||||||
mqtt_host="192.0.2.5",
|
mqtt_host="192.0.2.5",
|
||||||
mqtt_port=1883,
|
mqtt_port=1883,
|
||||||
|
mqtt_bind_address="192.168.10.5",
|
||||||
telemetry_poll_interval_seconds=1,
|
telemetry_poll_interval_seconds=1,
|
||||||
mqtt_publish_interval_seconds=4,
|
mqtt_publish_interval_seconds=4,
|
||||||
),
|
),
|
||||||
@@ -91,6 +94,7 @@ def test_contour_store_creates_and_updates_private_catalog(tmp_path: Path) -> No
|
|||||||
ssh_port=22,
|
ssh_port=22,
|
||||||
mqtt_host="127.0.0.1",
|
mqtt_host="127.0.0.1",
|
||||||
mqtt_port=1883,
|
mqtt_port=1883,
|
||||||
|
mqtt_bind_address="127.0.0.1",
|
||||||
telemetry_poll_interval_seconds=3,
|
telemetry_poll_interval_seconds=3,
|
||||||
mqtt_publish_interval_seconds=2,
|
mqtt_publish_interval_seconds=2,
|
||||||
),
|
),
|
||||||
@@ -119,6 +123,12 @@ def test_contour_router_exposes_catalog_and_safe_install_contract(
|
|||||||
assert "password" not in document["agent"]["environment"]
|
assert "password" not in document["agent"]["environment"]
|
||||||
assert document["agent"]["environment"]["MISSIONCORE_TELEMETRY_INTERVAL"] == "2s"
|
assert document["agent"]["environment"]["MISSIONCORE_TELEMETRY_INTERVAL"] == "2s"
|
||||||
assert document["ready"] is False
|
assert document["ready"] is False
|
||||||
|
network = _endpoint(
|
||||||
|
router,
|
||||||
|
"/api/v1/system/contours/{contour_id}/network",
|
||||||
|
"GET",
|
||||||
|
)
|
||||||
|
assert callable(network)
|
||||||
|
|
||||||
|
|
||||||
def test_contour_router_returns_404_for_unknown_contour(tmp_path: Path) -> None:
|
def test_contour_router_returns_404_for_unknown_contour(tmp_path: Path) -> None:
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import stat
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from k1link.web import compute_contour_network as network
|
||||||
|
from k1link.web.compute_contour_network import (
|
||||||
|
ComputeContourNetworkTarget,
|
||||||
|
NetworkOperationError,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _target(**updates: object) -> ComputeContourNetworkTarget:
|
||||||
|
values: dict[str, object] = {
|
||||||
|
"contour_id": "worker-006",
|
||||||
|
"expected_node_id": "DESKTOP-OPJ8J04",
|
||||||
|
"platform": "windows",
|
||||||
|
"worker_address": "",
|
||||||
|
"ssh_port": 22,
|
||||||
|
"mqtt_host": "mission-core.local",
|
||||||
|
"mqtt_port": 1883,
|
||||||
|
"mqtt_bind_address": "192.168.68.56",
|
||||||
|
"mqtt_publish_interval_seconds": 2,
|
||||||
|
}
|
||||||
|
values.update(updates)
|
||||||
|
return ComputeContourNetworkTarget(**values) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
def test_broker_apply_preserves_private_environment_and_changes_only_bind(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
root = tmp_path / "telemetry-plane"
|
||||||
|
root.mkdir()
|
||||||
|
environment = root / ".env"
|
||||||
|
environment.write_text(
|
||||||
|
"MISSIONCORE_MQTT_BIND_ADDRESS=192.168.1.5\n"
|
||||||
|
"MISSIONCORE_MQTT_PASSWORD=do-not-copy\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
environment.chmod(0o600)
|
||||||
|
(root / "compose.yaml").write_text("services: {}\n", encoding="utf-8")
|
||||||
|
compose_calls: list[Path] = []
|
||||||
|
monkeypatch.setattr(network, "_address_belongs_to_host", lambda _value: True)
|
||||||
|
monkeypatch.setattr(network, "_tcp_reachable", lambda *_args, **_kwargs: True)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
network,
|
||||||
|
"_run_compose_broker",
|
||||||
|
lambda path: compose_calls.append(path),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = network.apply_broker_network(_target(), root)
|
||||||
|
|
||||||
|
document = environment.read_text(encoding="utf-8")
|
||||||
|
assert "MISSIONCORE_MQTT_BIND_ADDRESS=192.168.68.56" in document
|
||||||
|
assert "MISSIONCORE_MQTT_PASSWORD=do-not-copy" in document
|
||||||
|
assert stat.S_IMODE(environment.stat().st_mode) == 0o600
|
||||||
|
assert compose_calls == [root]
|
||||||
|
assert result["target"] == "broker"
|
||||||
|
assert result["ready"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_broker_apply_rolls_back_environment_when_listener_does_not_open(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
root = tmp_path / "telemetry-plane"
|
||||||
|
root.mkdir()
|
||||||
|
environment = root / ".env"
|
||||||
|
before = (
|
||||||
|
"MISSIONCORE_MQTT_BIND_ADDRESS=192.168.1.5\n"
|
||||||
|
"MISSIONCORE_MQTT_PASSWORD=still-private\n"
|
||||||
|
)
|
||||||
|
environment.write_text(before, encoding="utf-8")
|
||||||
|
environment.chmod(0o600)
|
||||||
|
(root / "compose.yaml").write_text("services: {}\n", encoding="utf-8")
|
||||||
|
monkeypatch.setattr(network, "_address_belongs_to_host", lambda _value: True)
|
||||||
|
monkeypatch.setattr(network, "_tcp_reachable", lambda *_args, **_kwargs: False)
|
||||||
|
monkeypatch.setattr(network, "_run_compose_broker", lambda _path: None)
|
||||||
|
|
||||||
|
with pytest.raises(NetworkOperationError, match="не открыл"):
|
||||||
|
network.apply_broker_network(_target(), root)
|
||||||
|
|
||||||
|
assert environment.read_text(encoding="utf-8") == before
|
||||||
|
|
||||||
|
|
||||||
|
def test_worker_apply_uses_private_resolved_endpoint_and_confirms_service(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
captured: dict[str, Any] = {}
|
||||||
|
monkeypatch.setattr(
|
||||||
|
network,
|
||||||
|
"_resolve_addresses",
|
||||||
|
lambda _host, _port: ["192.168.68.56"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def run(
|
||||||
|
target: ComputeContourNetworkTarget,
|
||||||
|
script: str,
|
||||||
|
*,
|
||||||
|
timeout: float,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
captured.update({"target": target, "script": script, "timeout": timeout})
|
||||||
|
return {
|
||||||
|
"node_id": "DESKTOP-OPJ8J04",
|
||||||
|
"service_status": "Running",
|
||||||
|
"mqtt_host": "mission-core.local",
|
||||||
|
"mqtt_port": 1883,
|
||||||
|
"resolved_addresses": ["192.168.68.56"],
|
||||||
|
"broker_reachable": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(network, "_run_worker_powershell", run)
|
||||||
|
|
||||||
|
result = network.apply_worker_network(_target())
|
||||||
|
|
||||||
|
assert result["target"] == "worker"
|
||||||
|
assert result["ready"] is True
|
||||||
|
assert "__PAYLOAD_BASE64__" not in captured["script"]
|
||||||
|
assert "MISSIONCORE_MQTT_PASSWORD" not in captured["script"]
|
||||||
|
assert captured["timeout"] == 35
|
||||||
|
|
||||||
|
|
||||||
|
def test_worker_apply_rejects_public_mqtt_endpoint(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
network,
|
||||||
|
"_resolve_addresses",
|
||||||
|
lambda _host, _port: ["8.8.8.8"],
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(NetworkOperationError, match="локальной сети"):
|
||||||
|
network.apply_worker_network(_target(mqtt_host="broker.example.test"))
|
||||||
Reference in New Issue
Block a user