chore(node): preserve pre-canonicalization experiment snapshot
Historical working copy retained for audit before consolidation into main. The canonicalized plugin architecture and later fixes already live in main; this snapshot is not a release or a request to restore obsolete source layout.
This commit is contained in:
@@ -762,7 +762,15 @@ export async function probeRecordedPerceptionViewerSource(
|
||||
return { sourceUrl: endpoint.href, byteLength: declaredLength };
|
||||
}
|
||||
|
||||
export function RerunViewport({
|
||||
export function RerunViewport(props: RerunViewportProps) {
|
||||
// Even when source URLs coincide, another profile cannot inherit a mounted
|
||||
// viewer's playback, blueprint channels, recovery state or scene draft.
|
||||
const identity = props.profile.kind === "laboratory-result"
|
||||
? `${props.profile.kind}:${props.profile.resultId}` : props.profile.kind;
|
||||
return <RerunViewportInstance key={identity} {...props} />;
|
||||
}
|
||||
|
||||
function RerunViewportInstance({
|
||||
profile,
|
||||
onStatusChange,
|
||||
onSelectionChange,
|
||||
@@ -772,7 +780,7 @@ export function RerunViewport({
|
||||
onPerceptionLoadChange,
|
||||
onPointColorLoadChange,
|
||||
}: RerunViewportProps) {
|
||||
const recordedProfile = profile.kind === "recorded-session" ? profile : null;
|
||||
const recordedProfile = profile.kind === "recorded-session" || profile.kind === "laboratory-result" ? profile : null;
|
||||
const liveProfile = profile.kind === "live-acquisition" ? profile : null;
|
||||
const sourceUrl = profile.sourceUrl;
|
||||
const recordedArtifact = recordedProfile?.artifact ?? null;
|
||||
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
} from "../laboratory/CanonicalRecordedLabReplay";
|
||||
import type { CanonicalLabReplayDescriptor } from "../../core/laboratory/canonicalLabReplay";
|
||||
import type { ObservationSessionReplayLaunch } from "../../core/observation/sessionArchive";
|
||||
import { recordedSessionRerunProfile } from "../../core/observation/viewerProfile";
|
||||
import { laboratoryResultRerunProfile } from "../../core/observation/viewerProfile";
|
||||
import { resolveObservationSessionReplay } from "../../core/observation/useObservationSessions";
|
||||
import type { AIViewerLayer } from "../../core/observatory/aiComposition";
|
||||
import {
|
||||
@@ -305,7 +305,8 @@ export function CanonicalResultRerunReplay({
|
||||
accumulationSeconds: showLocalSlam ? sceneDraft.accumulationSeconds : 0,
|
||||
}), [sceneDraft, showLocalSlam, showSourcePoints, spatialMode]);
|
||||
const semanticVisible = showDDRNet || showEoMT;
|
||||
const profile = launch ? recordedSessionRerunProfile({
|
||||
const profile = launch ? laboratoryResultRerunProfile({
|
||||
resultId,
|
||||
sourceUrl: launch.replay.sourceUrl,
|
||||
artifact: {
|
||||
sourceUrl: launch.replay.sourceUrl,
|
||||
|
||||
@@ -113,13 +113,13 @@ const defaultQuickActions: Record<
|
||||
EnvironmentSurfaceId,
|
||||
readonly [string | null, string | null]
|
||||
> = {
|
||||
home: ["spatial-scene", "local-device"],
|
||||
fleet: ["contour-health", "local-device"],
|
||||
home: ["spatial-scene", "vehicles"],
|
||||
fleet: ["vehicles", "contour-health"],
|
||||
observation: ["spatial-scene", "cameras"],
|
||||
missions: ["mission-planner", null],
|
||||
data: ["recordings", "datasets"],
|
||||
system: ["modules", "integrations"],
|
||||
polygon: ["lab-archive", null],
|
||||
polygon: ["lab-archive", "local-device"],
|
||||
};
|
||||
|
||||
export function defaultEnvironmentSettings(): EnvironmentSettings {
|
||||
|
||||
@@ -85,6 +85,13 @@ export interface RecordedSessionRerunProfile {
|
||||
lockPerceptionCameraInteraction: boolean;
|
||||
}
|
||||
|
||||
/** A LAB owns its result presentation; it does not inherit session-view settings. */
|
||||
export interface LaboratoryResultRerunProfile
|
||||
extends Omit<RecordedSessionRerunProfile, "kind"> {
|
||||
kind: "laboratory-result";
|
||||
resultId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deprecated comparison-only contract for LAB artifacts that have not yet
|
||||
* been republished as a native Rerun sidecar. It must never be selected by a
|
||||
@@ -101,7 +108,8 @@ export interface LaboratoryRecordedEvidenceViewerProfile {
|
||||
|
||||
export type RerunViewerProfile =
|
||||
| LiveAcquisitionRerunProfile
|
||||
| RecordedSessionRerunProfile;
|
||||
| RecordedSessionRerunProfile
|
||||
| LaboratoryResultRerunProfile;
|
||||
|
||||
export type ObservationViewerProfile =
|
||||
| RerunViewerProfile
|
||||
@@ -119,11 +127,18 @@ export const LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE = Object.freeze({
|
||||
export function liveAcquisitionRerunProfile(
|
||||
input: Omit<LiveAcquisitionRerunProfile, "kind" | "clock">,
|
||||
): LiveAcquisitionRerunProfile {
|
||||
return { kind: "live-acquisition", clock: "stream_time", ...input };
|
||||
return { ...input, kind: "live-acquisition", clock: "stream_time" };
|
||||
}
|
||||
|
||||
export function recordedSessionRerunProfile(
|
||||
input: Omit<RecordedSessionRerunProfile, "kind" | "clock">,
|
||||
): RecordedSessionRerunProfile {
|
||||
return { kind: "recorded-session", clock: "session_time", ...input };
|
||||
return { ...input, kind: "recorded-session", clock: "session_time" };
|
||||
}
|
||||
|
||||
export function laboratoryResultRerunProfile(
|
||||
input: Omit<LaboratoryResultRerunProfile, "kind" | "clock">,
|
||||
): LaboratoryResultRerunProfile {
|
||||
if (!input.resultId.trim()) throw new Error("LAB result identity is required");
|
||||
return { ...input, kind: "laboratory-result", clock: "session_time" };
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ export const roots: RootDefinition[] = [
|
||||
label: "Парк",
|
||||
title: "Аппараты и устройства",
|
||||
eyebrow: "ПАРК / УСТРОЙСТВА",
|
||||
description: "Реестр аппаратов, локальное подключение, сенсоры и конфигурации борта.",
|
||||
description: "Реестр аппаратов, бортовые компьютеры, сенсоры и конфигурации борта.",
|
||||
statement: "Одинаково подключать одиночный стенд, наземную платформу и будущий рой.",
|
||||
accent: "АППАРАТЫ И ПОЛЕЗНАЯ НАГРУЗКА",
|
||||
},
|
||||
@@ -203,11 +203,11 @@ export const workspaces: WorkspaceDefinition[] = [
|
||||
},
|
||||
{
|
||||
id: "local-device",
|
||||
root: "fleet",
|
||||
label: "Подключение",
|
||||
title: "Подключение",
|
||||
eyebrow: "ПАРК / ТЕКУЩИЙ АДАПТЕР",
|
||||
description: "Выбор модели, сценарий установленного плагина и запуск доступного потока.",
|
||||
root: "polygon",
|
||||
label: "Тестовые устройства",
|
||||
title: "Тестовые устройства",
|
||||
eyebrow: "LAB / ТЕСТОВЫЕ УСТРОЙСТВА",
|
||||
description: "Подключение устройств к компьютеру оператора для испытаний и записи.",
|
||||
icon: "network",
|
||||
kind: "device",
|
||||
groups: [],
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import {useMemo} from 'react';
|
||||
import {createIsolatedRerunHost} from '../../components/rerun/isolatedRerunHost';
|
||||
import {SensorWorkspace} from '../../../../../packages/sensor-ui/src/SensorWorkspace';
|
||||
import type {SensorInventory,SensorTransport} from '../../../../../packages/sensor-ui/src/contracts';
|
||||
import {fleetRequest} from '../../core/fleet/useFleet';
|
||||
export function VehicleSensors({vehicleID,enabled,onDetailChange}:{vehicleID:string;enabled:boolean;onDetailChange:(open:boolean)=>void}){
|
||||
const transport=useMemo<SensorTransport>(()=>({
|
||||
enrollment:{
|
||||
state:()=>fleetRequest(`/${encodeURIComponent(vehicleID)}/devices/enrollment`),
|
||||
submit:value=>fleetRequest(`/${encodeURIComponent(vehicleID)}/devices/enrollment/operations`,'POST',value),
|
||||
operation:id=>fleetRequest(`/${encodeURIComponent(vehicleID)}/devices/enrollment/operations/${encodeURIComponent(id)}`),
|
||||
},
|
||||
inventory:async()=>{const fleet=await fleetRequest<{items:{id:string;connectivity:string;sensor_state:SensorInventory}[]}>();const value=fleet.items.find(v=>v.id===vehicleID);if(!value)throw new Error('Аппарат не найден.');return {...value.sensor_state,fresh:value.connectivity==='online'};},
|
||||
subscribe:(receive,unavailable)=>{const events=new EventSource('/api/v1/fleet/events');events.onmessage=e=>{try{const value=JSON.parse(e.data).items.find((v:{id:string})=>v.id===vehicleID);if(value)receive({...value.sensor_state,fresh:value.connectivity==='online'});else unavailable();}catch{unavailable();}};events.onerror=unavailable;return()=>events.close();},
|
||||
submit:value=>fleetRequest(`/${encodeURIComponent(vehicleID)}/devices/operations`,'POST',value),
|
||||
operation:id=>fleetRequest(`/${encodeURIComponent(vehicleID)}/devices/operations/${encodeURIComponent(id)}`),
|
||||
}),[vehicleID]);
|
||||
return <SensorWorkspace key={vehicleID} transport={transport} enabled={enabled} onDetailChange={onDetailChange}/>;
|
||||
return <SensorWorkspace createRerunHost={createIsolatedRerunHost} key={vehicleID} transport={transport} enabled={enabled} onDetailChange={onDetailChange}/>;
|
||||
}
|
||||
|
||||
@@ -143,7 +143,7 @@ test("Observatory owns a bounded explicit-open lifecycle around the shared recor
|
||||
/advanced-index|resolveObservationSessionReplay|resolveCanonicalLabReplay|RerunViewport/,
|
||||
);
|
||||
assert.equal(sharedReplay.match(/<RerunViewport\b/g)?.length, 1);
|
||||
assert.match(sharedReplay, /recordedSessionRerunProfile/);
|
||||
assert.match(sharedReplay, /laboratoryResultRerunProfile/);
|
||||
assert.match(sharedReplay, /useState<0 \| 1>\(hasTgs \? 1 : 0\)/);
|
||||
assert.match(sharedReplay, /hasTgs \? 0\.000001 : 0/);
|
||||
for (const adapter of ["CanonicalVegetationRerunReplay", "PortableResultReplay"]) {
|
||||
|
||||
@@ -1676,7 +1676,7 @@ test("physical-command guidance projects backend policy without exposing reason
|
||||
);
|
||||
});
|
||||
|
||||
test("restart-recovery guidance describes automatic recovery without protocol ceremony", () => {
|
||||
test("restart-recovery guidance offers an explicit read-only recovery", () => {
|
||||
const deniedFresh = {
|
||||
connection_policy: {
|
||||
schema_version: "missioncore.xgrids-k1-connection-policy/v1",
|
||||
@@ -1712,7 +1712,7 @@ test("restart-recovery guidance describes automatic recovery without protocol ce
|
||||
);
|
||||
assert.deepEqual(durableGuidance, {
|
||||
reason: "В свежем Bluetooth-поиске не найден K1, связанный с незавершённой записью.",
|
||||
nextAction: "Дождитесь автоматического восстановления сохранённого подключения K1.",
|
||||
nextAction: "Нажмите «Переподключиться», чтобы проверить сохранённое подключение K1.",
|
||||
});
|
||||
assert.doesNotMatch(
|
||||
`${durableGuidance.reason} ${durableGuidance.nextAction}`,
|
||||
@@ -1725,7 +1725,7 @@ test("restart-recovery guidance describes automatic recovery without protocol ce
|
||||
deniedFresh,
|
||||
"observe-fresh-device-network",
|
||||
).nextAction,
|
||||
"Дождитесь автоматического восстановления связи с тем же K1.",
|
||||
"Нажмите «Переподключиться», чтобы проверить связь с тем же K1.",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -3461,7 +3461,7 @@ test("each terminal explicit provisioning click starts a fresh operation identit
|
||||
|
||||
assert.match(
|
||||
connectRecovery,
|
||||
/failedOperation\?\.status === "succeeded"[\s\S]*?!exactAppliedNetworkIntentCompleted\([\s\S]*?&& !hasExactConnectionReady/,
|
||||
/observeProvisioningRequest\([\s\S]*?observedState = observation\.state[\s\S]*?const exactNetworkIntentCompleted = exactAppliedNetworkIntentCompleted\(/,
|
||||
);
|
||||
assert.match(
|
||||
connectRecovery,
|
||||
@@ -4009,7 +4009,7 @@ test("failed network-profile writes close the UI session with a fresh explicit n
|
||||
|
||||
assert.equal(
|
||||
message,
|
||||
"Bluetooth-периферия завершила сетевую операцию ошибкой. Команда могла быть принята K1; итог текущей попытки не подтверждён. Автоматический повтор команды K1 не отправлялся. Сессия подключения в интерфейсе сброшена. Выполните новый поиск Bluetooth, выберите K1 и запустите подключение ещё раз.",
|
||||
"Bluetooth-периферия завершила сетевую операцию ошибкой. Команда могла быть принята K1; итог текущей попытки не подтверждён. Автоматический повтор команды K1 не отправлялся. Проверьте состояние K1 через «Переподключиться» или начните новое подключение через поиск Bluetooth.",
|
||||
);
|
||||
assert.doesNotMatch(message, /Bleak|GATT|ATT/i);
|
||||
|
||||
@@ -4024,8 +4024,8 @@ test("failed network-profile writes close the UI session with a fresh explicit n
|
||||
});
|
||||
assert.match(attMessage, /ATT 4 INVALID_PDU/);
|
||||
assert.match(attMessage, /Автоматический повтор команды K1 не отправлялся/);
|
||||
assert.match(attMessage, /Сессия подключения в интерфейсе сброшена/);
|
||||
assert.match(attMessage, /новый поиск Bluetooth, выберите K1/);
|
||||
assert.match(attMessage, /Проверьте состояние K1 через «Переподключиться»/);
|
||||
assert.match(attMessage, /новое подключение через поиск Bluetooth/);
|
||||
|
||||
const preWriteAttMessage = networkProvisionFailureMessage({
|
||||
action: "network.provision",
|
||||
@@ -4059,7 +4059,7 @@ test("post-dispatch ambiguity resets the UI session without an automatic retry",
|
||||
|
||||
assert.equal(
|
||||
message,
|
||||
"После BLE-команды K1 вернул сетевой статус, неотличимый от исходного; итог текущей попытки подключения не подтверждён. Автоматический повтор команды K1 не отправлялся. Сессия подключения в интерфейсе сброшена. Выполните новый поиск Bluetooth, выберите K1 и запустите подключение ещё раз.",
|
||||
"После BLE-команды K1 вернул сетевой статус, неотличимый от исходного; итог текущей попытки подключения не подтверждён. Автоматический повтор команды K1 не отправлялся. Проверьте состояние K1 через «Переподключиться» или начните новое подключение через поиск Bluetooth.",
|
||||
);
|
||||
assert.doesNotMatch(message, /ручн|read-only|защитный барьер/i);
|
||||
});
|
||||
@@ -4247,7 +4247,7 @@ test("host Wi-Fi failures close the attempt and require a fresh explicit connect
|
||||
|
||||
assert.equal(
|
||||
operationTimeout,
|
||||
"Локальная операция подготовки Wi‑Fi не завершилась вовремя; итог текущей попытки подключения не подтверждён. Автоматический повтор команды K1 не отправлялся. Сессия подключения в интерфейсе сброшена. Выполните новый поиск Bluetooth, выберите K1 и запустите подключение ещё раз.",
|
||||
"Локальная операция подготовки Wi‑Fi не завершилась вовремя; итог текущей попытки подключения не подтверждён. Автоматический повтор команды K1 не отправлялся. Проверьте состояние K1 через «Переподключиться» или начните новое подключение через поиск Bluetooth.",
|
||||
);
|
||||
assert.doesNotMatch(operationTimeout, /получите пароль|пароль отсутствует/i);
|
||||
assert.match(missingNetwork, /K1 принял команду Quick Connect/);
|
||||
|
||||
@@ -218,8 +218,8 @@ test("K1 connection surface enforces one scan, local selection, and one Apply",
|
||||
);
|
||||
assert.match(provisioning, /scanSecondsRemaining/);
|
||||
assert.match(provisioning, /setInterval\(updateCountdown, 250\)/);
|
||||
assert.match(provisioning, /Поиск Bluetooth · \{scanSecondsRemaining \?\? 6\} с/);
|
||||
assert.match(provisioning, /scanWithResult\(\{[^}]*durationSeconds:\s*6/);
|
||||
assert.match(provisioning, /Поиск Bluetooth · до \{scanSecondsRemaining \?\? BLE_DISCOVERY_TIMEOUT_SECONDS\} с/);
|
||||
assert.match(provisioning, /scanWithResult\(\{[^}]*durationSeconds:\s*BLE_DISCOVERY_TIMEOUT_SECONDS/);
|
||||
assert.doesNotMatch(provisioning, /K1 уже доступен|Сетевой адрес K1 доступен/);
|
||||
assert.doesNotMatch(
|
||||
provisioning,
|
||||
@@ -284,7 +284,7 @@ test("K1 click-owned actions are fenced without hidden frontend continuations",
|
||||
provisioning.indexOf("const verifyAppliedNetwork"),
|
||||
);
|
||||
assert.equal((search.match(/scanWithResult\(/g) ?? []).length, 1);
|
||||
assert.match(search, /durationSeconds:\s*6/);
|
||||
assert.match(search, /durationSeconds:\s*BLE_DISCOVERY_TIMEOUT_SECONDS/);
|
||||
assert.equal((apply.match(/await connect\(/g) ?? []).length, 1);
|
||||
assert.doesNotMatch(
|
||||
apply,
|
||||
@@ -680,7 +680,7 @@ test("automatic K1 live start opens the selected delivered camera despite an old
|
||||
assert.match(layout, /source\.capabilities\.overlay/);
|
||||
});
|
||||
|
||||
test("K1 connect errors reset the UI session without exposing reconciliation ceremony", () => {
|
||||
test("K1 connect errors preserve the result and expose explicit recovery", () => {
|
||||
const runtime = readFileSync(
|
||||
join(pluginFrontendRoot, "useXgridsK1Runtime.ts"),
|
||||
"utf8",
|
||||
@@ -691,15 +691,17 @@ test("K1 connect errors reset the UI session without exposing reconciliation cer
|
||||
assert.notEqual(connectEnd, -1);
|
||||
const connectFlow = runtime.slice(connectStart, connectEnd);
|
||||
|
||||
assert.match(connectFlow, /operationByIdempotencyKey\(/);
|
||||
assert.match(connectFlow, /failedOperation\?\.status === "succeeded"/);
|
||||
assert.match(connectFlow, /resetConnectSessionMessage\(/);
|
||||
assert.match(connectFlow, /observeProvisioningRequest\(/);
|
||||
assert.match(connectFlow, /operation\?\.status !== "succeeded"/);
|
||||
assert.match(connectFlow, /networkProvisionFailureMessage\(/);
|
||||
assert.doesNotMatch(
|
||||
connectFlow,
|
||||
/требует ручной проверки|измените параметры только после проверки устройства|Сохранён тот же ключ/,
|
||||
);
|
||||
assert.match(runtime, /Сессия подключения в интерфейсе сброшена/);
|
||||
assert.match(runtime, /новый поиск Bluetooth, выберите K1 и запустите подключение ещё раз/);
|
||||
const networkFlow = readFileSync(join(pluginFrontendRoot, "networkProvisioning.ts"), "utf8");
|
||||
assert.match(networkFlow, /Проверьте состояние K1 через «Переподключиться»/);
|
||||
assert.match(networkFlow, /новое подключение через поиск Bluetooth/);
|
||||
assert.equal((networkFlow.match(/await context\.send\(/g) ?? []).length, 1);
|
||||
const verifyStart = runtime.indexOf("const verifyConnection = useCallback(");
|
||||
const verifyEnd = runtime.indexOf("const probeConfiguredEndpoint = useCallback(", verifyStart);
|
||||
const verifyFlow = runtime.slice(verifyStart, verifyEnd);
|
||||
@@ -729,7 +731,7 @@ test("K1 provisioning keeps the operator draft separate from the backend lease",
|
||||
assert.match(selection, /requestExplicitProvisioning\(device\.device_id/);
|
||||
assert.equal((apply.match(/await connect\(/g) ?? []).length, 1);
|
||||
assert.doesNotMatch(apply, /scanWithResult\(|verifyConnection\(|candidateRefresh/);
|
||||
assert.match(provisioning, /scanWithResult\(\{ durationSeconds: 6 \}\)/);
|
||||
assert.match(provisioning, /scanWithResult\(\{ durationSeconds: BLE_DISCOVERY_TIMEOUT_SECONDS \}\)/);
|
||||
assert.match(
|
||||
provisioning,
|
||||
/onChange=\{\(event\) => setPassword\(event\.target\.value\)\}/,
|
||||
|
||||
@@ -65,7 +65,7 @@ test("default environment exposes every current product page", () => {
|
||||
"system",
|
||||
"polygon",
|
||||
]);
|
||||
assert.equal(defaults.pages.fleet.primaryWorkspaceId, "contour-health");
|
||||
assert.equal(defaults.pages.fleet.primaryWorkspaceId, "vehicles");
|
||||
assert.equal(defaults.pages.observation.primaryWorkspaceId, "spatial-scene");
|
||||
});
|
||||
|
||||
|
||||
@@ -250,7 +250,7 @@ test("K1 one-intent source contract makes mode reset explicit and keeps device I
|
||||
"const chooseAnother",
|
||||
);
|
||||
|
||||
assert.match(search, /scanWithResult\(\{[^}]*durationSeconds:\s*6/);
|
||||
assert.match(search, /scanWithResult\(\{[^}]*durationSeconds:\s*BLE_DISCOVERY_TIMEOUT_SECONDS/);
|
||||
assert.equal((search.match(/scanWithResult\(/g) ?? []).length, 1);
|
||||
assert.doesNotMatch(search, /\b(?:connect|verifyConnection|submitConnect)\s*\(/);
|
||||
|
||||
@@ -295,7 +295,7 @@ test("K1 one-intent source contract makes mode reset explicit and keeps device I
|
||||
}
|
||||
assert.equal((source.match(/buttonLabel:\s*"Применить"/g) ?? []).length, 3);
|
||||
assert.match(source, /устарел|stale/i);
|
||||
assert.match(source, /неизвест|outcome-unknown|safe_to_retry/i);
|
||||
assert.match(source, /network_outcome_unknown|safe_to_retry/i);
|
||||
});
|
||||
|
||||
test("exact network-applied REST proof completes frontend Apply before control is ready", () => {
|
||||
@@ -377,7 +377,7 @@ test("exact network-applied REST proof completes frontend Apply before control i
|
||||
);
|
||||
assert.match(
|
||||
connectFlow,
|
||||
/failedOperation\?\.status === "succeeded"[\s\S]*?!exactAppliedNetworkIntentCompleted\([\s\S]*?&& !hasExactConnectionReady/,
|
||||
/observeProvisioningRequest\([\s\S]*?observedState = observation\.state[\s\S]*?const exactNetworkIntentCompleted = exactAppliedNetworkIntentCompleted\(/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
connectFlow,
|
||||
@@ -1359,7 +1359,7 @@ function actionByLabel(node, label) {
|
||||
return actionByLabel(node.props.children, label);
|
||||
}
|
||||
|
||||
function renderProvisioningWithAttempt(props, presentation) {
|
||||
function renderProvisioningWithAttempt(props, presentation, afterSearch = false) {
|
||||
function AttemptHarness() {
|
||||
const internals =
|
||||
React.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;
|
||||
@@ -1367,6 +1367,12 @@ function renderProvisioningWithAttempt(props, presentation) {
|
||||
assert.equal(typeof dispatcher?.useState, "function");
|
||||
const originalUseState = dispatcher.useState;
|
||||
dispatcher.useState = (initialState) => {
|
||||
if (afterSearch && initialState === emptySearchPresentation) {
|
||||
const state = props.controller.state;
|
||||
return [{ sequence: 1, snapshotRuntimeId: state.snapshot_runtime_id,
|
||||
connectionMode: props.desiredMode, desiredModeRevision: state.desired_connection_mode_revision,
|
||||
active: false, completedDiscoveryGeneration: state.ble_discovery_generation }, () => undefined];
|
||||
}
|
||||
if (initialState === emptyProvisioningAttemptPresentation) {
|
||||
return [presentation, () => undefined];
|
||||
}
|
||||
@@ -2487,10 +2493,11 @@ test("toolbar reset in one runtime rejects late Scan A and admits exactly one Sc
|
||||
}
|
||||
});
|
||||
|
||||
test("local connection navigation and selector copy use process nouns", () => {
|
||||
test("operator-local connection belongs to LAB and retains all test modes", () => {
|
||||
const localConnection = workspaces.find((workspace) => workspace.id === "local-device");
|
||||
assert.equal(localConnection?.label, "Подключение");
|
||||
assert.equal(localConnection?.title, "Подключение");
|
||||
assert.equal(localConnection?.root, "polygon");
|
||||
assert.equal(localConnection?.label, "Тестовые устройства");
|
||||
assert.equal(localConnection?.title, "Тестовые устройства");
|
||||
assert.equal(model().displayName, "XGRIDS LixelKity K1");
|
||||
assert.deepEqual(
|
||||
connectionModeOptions.map(({ label }) => label),
|
||||
@@ -7185,7 +7192,7 @@ test("a current-authority Bluetooth scan owns Step 01 and no network step", () =
|
||||
});
|
||||
|
||||
assert.match(markup, /<span>01<\/span>/);
|
||||
assert.match(markup, /Поиск Bluetooth · 6 с/);
|
||||
assert.match(markup, /Поиск Bluetooth · до 20 с/);
|
||||
assert.equal(
|
||||
(markup.match(/class="nodedc-activity-indicator"/g) ?? []).length,
|
||||
1,
|
||||
@@ -7478,7 +7485,7 @@ test("applied-network recovery spends the old intent and admits only explicit se
|
||||
search,
|
||||
/const recoveryScanAttempt = appliedRecoveryEscape[\s\S]*?canScanAppliedRecovery[\s\S]*?unresolvedAppliedAttempt/,
|
||||
);
|
||||
assert.match(search, /scanResult = await scanWithResult\(\{ durationSeconds: 6 \}\)/);
|
||||
assert.match(search, /scanResult = await scanWithResult\(\{ durationSeconds: BLE_DISCOVERY_TIMEOUT_SECONDS \}\)/);
|
||||
assert.match(
|
||||
search,
|
||||
/if \(scanResult\.succeeded && recoveryAttemptKey\) \{\s*setEscapedAppliedAttemptKey\(recoveryAttemptKey\)/,
|
||||
@@ -7708,3 +7715,56 @@ test("contour health never promotes selection or replay metrics to live authorit
|
||||
});
|
||||
assert.equal(replay.aiActive, false);
|
||||
});
|
||||
|
||||
|
||||
test("the recovery panel explains the reviewed K1 network-not-found reply", () => {
|
||||
const state = terminalConnectionRecoveryState();
|
||||
state.connection_attempt.public_error_code = "k1-wifi-network-not-found";
|
||||
const markup = renderToStaticMarkup(createElement(K1OperatorError, {
|
||||
attempt: state.connection_attempt, onRefresh() {}, onClear() {},
|
||||
}));
|
||||
assert.match(markup, /K1 не смог найти указанную сеть Wi‑Fi/);
|
||||
assert.match(markup, /точное название сети/);
|
||||
assert.doesNotMatch(markup, /ошибкой Bluetooth|INVALID_PDU/);
|
||||
});
|
||||
|
||||
test("a reviewed Wi-Fi rejection offers explicit network setup for the same K1", () => {
|
||||
const state = terminalConnectionRecoveryState();
|
||||
state.connection_attempt.public_error_code = "k1-wifi-network-not-found";
|
||||
const controller = {
|
||||
...provisioningController(state), error: "private transport error",
|
||||
errorCorrelation: {
|
||||
action: "connect", runtimeId: state.snapshot_runtime_id, leaseGeneration: 0,
|
||||
connectionAttemptId: state.connection_attempt.attempt_id,
|
||||
},
|
||||
getConnectionRecoveryObservationTarget: () => recommendedConnectionRecoveryObservationTarget(state),
|
||||
};
|
||||
const markup = renderProvisioningWithAttempt({ controller, desiredMode: "bridge" },
|
||||
provisioningAttemptPresentation({ snapshotRuntimeId: state.snapshot_runtime_id,
|
||||
attemptId: state.connection_attempt.attempt_id, localPhase: "failed" }), true);
|
||||
assert.equal(buttonMarkupWithText(markup, "Указать сеть Wi‑Fi заново").length, 1);
|
||||
assert.doesNotMatch(markup, /Подключить новый K1|private transport error/);
|
||||
});
|
||||
|
||||
test("a failed Connect after completed Bluetooth search exposes recovery instead of a locked form", () => {
|
||||
const state = terminalConnectionRecoveryState();
|
||||
state.ble_discovery_generation = 6;
|
||||
state.connection_attempt.public_error_code = "BleakGATTProtocolError";
|
||||
state.connection_attempt.stage = "gatt-write-failed";
|
||||
const controller = {
|
||||
...provisioningController(state),
|
||||
error: "private transport exception must stay hidden",
|
||||
errorCorrelation: {
|
||||
action: "connect", runtimeId: state.snapshot_runtime_id, leaseGeneration: 0,
|
||||
connectionAttemptId: state.connection_attempt.attempt_id,
|
||||
},
|
||||
getConnectionRecoveryObservationTarget: () => recommendedConnectionRecoveryObservationTarget(state),
|
||||
};
|
||||
const markup = renderProvisioningWithAttempt({ controller, desiredMode: "bridge" },
|
||||
provisioningAttemptPresentation({ snapshotRuntimeId: state.snapshot_runtime_id,
|
||||
attemptId: state.connection_attempt.attempt_id, localPhase: "failed" }), true);
|
||||
assert.equal(buttonMarkupWithText(markup, "Переподключиться").length, 1);
|
||||
assert.equal(buttonMarkupWithText(markup, "Подключить новый K1").length, 1);
|
||||
assert.match(markup, /K1 ответил ошибкой Bluetooth/);
|
||||
assert.doesNotMatch(markup, /Подключение не выполнено|дождитесь, пока система|private transport exception|Пароль передан/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { before, after, test } from "node:test";
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let observeProvisioningRequest;
|
||||
let networkProvisionFailureMessage;
|
||||
let ApiError;
|
||||
before(async () => {
|
||||
server = await createServer({ appType: "custom", logLevel: "silent", server: { middlewareMode: true } });
|
||||
({ observeProvisioningRequest, networkProvisionFailureMessage } = await server.ssrLoadModule("@xgrids-k1/frontend/networkProvisioning.ts"));
|
||||
({ ApiError } = await server.ssrLoadModule("@xgrids-k1/frontend/api.ts"));
|
||||
});
|
||||
after(async () => { await server?.close(); });
|
||||
|
||||
test("reviewed station errors explain Wi-Fi recovery without claiming a Bluetooth failure", () => {
|
||||
for (const [code, pattern] of [
|
||||
["k1-wifi-network-not-found", /точное название сети/],
|
||||
["k1-wifi-credentials-required", /введите пароль нужной сети заново/],
|
||||
]) {
|
||||
const message = networkProvisionFailureMessage({ status: "failed", error: { code } });
|
||||
assert.match(message, pattern);
|
||||
assert.doesNotMatch(message, /INVALID_PDU|ошибкой Bluetooth|Переподключиться/);
|
||||
}
|
||||
assert.match(networkProvisionFailureMessage({ status: "failed", error: {
|
||||
code: "BleakGATTProtocolError", ble_att_error_code: 4, ble_att_error_name: "INVALID_PDU",
|
||||
} }), /INVALID_PDU/);
|
||||
assert.match(networkProvisionFailureMessage({ status: "failed", error: {
|
||||
code: "network-not-found",
|
||||
} }), /macOS/);
|
||||
});
|
||||
|
||||
const request = { device_id: "synthetic-k1", connection_mode: "bridge", idempotency_key: "explicit-one" };
|
||||
function snapshot(status, revision = 2) {
|
||||
return {
|
||||
snapshot_runtime_id: "runtime-one",
|
||||
snapshot_runtime_started_at_utc: "2026-09-06T00:00:00Z",
|
||||
snapshot_revision: revision,
|
||||
operations: status ? [{
|
||||
operation_id: "op-one", action: "network.provision", idempotency_key: request.idempotency_key,
|
||||
status, error: status === "failed" ? {
|
||||
code: "BleakGATTProtocolError", ble_att_error_code: 4, ble_att_error_name: "INVALID_PDU",
|
||||
device_write_attempted: true, device_write_confirmed: false,
|
||||
safe_to_retry: false, side_effect_status: "unknown",
|
||||
} : null,
|
||||
}] : [],
|
||||
};
|
||||
}
|
||||
function context(overrides = {}) {
|
||||
let current = snapshot(null, 1);
|
||||
const calls = [];
|
||||
return {
|
||||
calls,
|
||||
initialState: current,
|
||||
send: async (input) => { calls.push(["send", input]); throw new ApiError("transport", 502); },
|
||||
readState: async () => { calls.push(["read"]); return snapshot("failed"); },
|
||||
acceptState: (incoming) => { if (incoming.snapshot_revision >= current.snapshot_revision) current = incoming; },
|
||||
currentState: () => current,
|
||||
assertCurrent() {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("HTTP 502 retains the exact failed snapshot and ATT facts after one submission", async () => {
|
||||
const ctx = context();
|
||||
const result = await observeProvisioningRequest(request, ctx);
|
||||
assert.deepEqual(ctx.calls.map(([kind]) => kind), ["send", "read"]);
|
||||
assert.equal(ctx.calls[0][1], request);
|
||||
assert.equal(result.operation.operation_id, "op-one");
|
||||
assert.equal(result.operation.error.ble_att_error_name, "INVALID_PDU");
|
||||
assert.equal(result.state.operations[0], result.operation);
|
||||
assert.equal(result.transportError.status, 502);
|
||||
});
|
||||
|
||||
test("an existing failed or successful intent is never resubmitted", async () => {
|
||||
for (const status of ["failed", "succeeded"]) {
|
||||
const ctx = context({ initialState: snapshot(status) });
|
||||
const result = await observeProvisioningRequest(request, ctx);
|
||||
assert.equal(result.operation.status, status);
|
||||
assert.equal(ctx.calls.length, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test("lost HTTP response can observe journal success without another command", async () => {
|
||||
const ctx = context({ readState: async () => snapshot("succeeded") });
|
||||
const result = await observeProvisioningRequest(request, ctx);
|
||||
assert.equal(result.operation.status, "succeeded");
|
||||
assert.equal(ctx.calls.filter(([kind]) => kind === "send").length, 1);
|
||||
});
|
||||
|
||||
test("an admitted pending intent is followed only by bounded local state reads", async () => {
|
||||
let now = 0;
|
||||
const ctx = context({
|
||||
initialState: snapshot("running"),
|
||||
settlementOptions: { now: () => now, wait: async (ms) => { now += ms; } },
|
||||
});
|
||||
const result = await observeProvisioningRequest(request, ctx);
|
||||
assert.equal(result.operation.status, "failed");
|
||||
assert.deepEqual(ctx.calls.map(([kind]) => kind), ["read"]);
|
||||
});
|
||||
|
||||
test("newer WebSocket failure outranks a stale running REST result", async () => {
|
||||
const newer = snapshot("failed", 7);
|
||||
const ctx = context({
|
||||
send: async () => snapshot("running", 5),
|
||||
currentState: () => newer,
|
||||
});
|
||||
const result = await observeProvisioningRequest(request, ctx);
|
||||
assert.equal(result.state, newer);
|
||||
assert.equal(result.operation.status, "failed");
|
||||
assert.equal(ctx.calls.length, 0);
|
||||
});
|
||||
|
||||
test("superseding an intent after dispatch stops continuation without replay", async () => {
|
||||
let active = true;
|
||||
const ctx = context({
|
||||
send: async () => { active = false; throw new ApiError("lost", 502); },
|
||||
assertCurrent: () => { if (!active) throw new Error("superseded"); },
|
||||
});
|
||||
await assert.rejects(observeProvisioningRequest(request, ctx), /superseded/);
|
||||
assert.equal(ctx.calls.length, 0);
|
||||
});
|
||||
|
||||
test("an unrelated journal row cannot confirm the current command", async () => {
|
||||
const unrelated = snapshot("succeeded");
|
||||
unrelated.operations[0].idempotency_key = "another-intent";
|
||||
const ctx = context({ readState: async () => unrelated });
|
||||
const result = await observeProvisioningRequest(request, ctx);
|
||||
assert.equal(result.operation, null);
|
||||
assert.equal(result.transportError.status, 502);
|
||||
assert.equal(ctx.calls.length, 1);
|
||||
});
|
||||
@@ -27,7 +27,7 @@ async function read(relativePath) {
|
||||
test("Observatory is the third independent Polygon workspace", () => {
|
||||
assert.deepEqual(
|
||||
productModel.workspacesForRoot("polygon").map(({ id }) => id),
|
||||
["lab-archive", "simulations", "observatory"],
|
||||
["lab-archive", "simulations", "observatory", "local-device"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
productModel.workspaceById("observatory"),
|
||||
@@ -138,7 +138,7 @@ test("Observatory mounts the one shared canonical replay only after explicit adm
|
||||
/RerunViewport|ObservationSessionSelect|resolveObservationSessionReplay|resolveCanonicalLabReplay|deleteObservationSession|setInterval|setTimeout|useObservationSessions|useAdvancedLaboratoryCatalog|advanced-index|prefetch|preload/,
|
||||
);
|
||||
assert.equal(sharedReplay.match(/<RerunViewport\b/g)?.length, 1);
|
||||
assert.match(sharedReplay, /recordedSessionRerunProfile/);
|
||||
assert.match(sharedReplay, /laboratoryResultRerunProfile/);
|
||||
assert.match(sharedReplay, /timelineStartSeconds/);
|
||||
assert.doesNotMatch(sharedReplay, /live-acquisition|lab-recorded-evidence/);
|
||||
assert.match(
|
||||
|
||||
@@ -324,7 +324,7 @@ test("Polygon exposes one dataset surface and keeps legacy links compatible", ()
|
||||
assert.equal(workspaceById("datasets").kind, "datasets");
|
||||
assert.deepEqual(
|
||||
workspacesForRoot("polygon").map(({ id }) => id),
|
||||
["lab-archive", "simulations", "observatory"],
|
||||
["lab-archive", "simulations", "observatory", "local-device"],
|
||||
);
|
||||
assert.equal(
|
||||
workspacesForRoot("system").some(({ id }) => id === "polygon-run"),
|
||||
|
||||
@@ -29,7 +29,7 @@ test("top navigation has no Center and Park owns contour health first", () => {
|
||||
assert.equal(productModel.workspaceById("contour-health")?.root, "fleet");
|
||||
assert.deepEqual(
|
||||
productModel.workspacesForRoot("polygon").map(({ id }) => id),
|
||||
["lab-archive", "simulations", "observatory"],
|
||||
["lab-archive", "simulations", "observatory", "local-device"],
|
||||
);
|
||||
assert.equal(productModel.workspaceById("lab-archive")?.kind, "lab-archive");
|
||||
assert.deepEqual(
|
||||
|
||||
@@ -7,6 +7,7 @@ let server;
|
||||
let LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE;
|
||||
let liveAcquisitionRerunProfile;
|
||||
let recordedSessionRerunProfile;
|
||||
let laboratoryResultRerunProfile;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
@@ -18,6 +19,7 @@ before(async () => {
|
||||
LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE,
|
||||
liveAcquisitionRerunProfile,
|
||||
recordedSessionRerunProfile,
|
||||
laboratoryResultRerunProfile,
|
||||
} = await server.ssrLoadModule("/src/core/observation/viewerProfile.ts"));
|
||||
});
|
||||
|
||||
@@ -86,3 +88,14 @@ test("the old LAB transport is fenced as explicit legacy comparison only", () =>
|
||||
});
|
||||
assert.equal(Object.isFrozen(LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE), true);
|
||||
});
|
||||
|
||||
test("LAB has its own immutable result identity and discriminator", () => {
|
||||
const input = {resultId:"E38-result-1",sourceUrl:"/shared.rrd",kind:"recorded-session",clock:"stream_time"};
|
||||
const lab = laboratoryResultRerunProfile(input);
|
||||
assert.equal(lab.kind,"laboratory-result");
|
||||
assert.equal(lab.clock,"session_time");
|
||||
assert.equal(lab.resultId,"E38-result-1");
|
||||
assert.equal(recordedSessionRerunProfile({...input}).kind,"recorded-session");
|
||||
assert.equal(liveAcquisitionRerunProfile({...input}).kind,"live-acquisition");
|
||||
assert.throws(()=>laboratoryResultRerunProfile({...input,resultId:" "}));
|
||||
});
|
||||
|
||||
@@ -80,6 +80,12 @@ func run() error {
|
||||
return err
|
||||
}
|
||||
pairing.Sensors = app.Sensors
|
||||
app.DeviceEnrollment, err = node.OpenDeviceEnrollment(*dir, nodeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pairing.DeviceEnrollment = app.DeviceEnrollment
|
||||
app.Sensors.NetworkDevices = app.DeviceEnrollment
|
||||
app.Access = &node.AccessStore{Path: filepath.Join(*dir, "ssh-keys.json"), Users: func() []string { return node.LocalAdmins("/") }}
|
||||
if err := os.MkdirAll(filepath.Dir(*socket), 0700); err != nil {
|
||||
return err
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type EnrollmentCommand struct {
|
||||
ID string `json:"operation_id"`
|
||||
NodeID string `json:"node_id"`
|
||||
RuntimeID string `json:"runtime_id"`
|
||||
Action string `json:"action"`
|
||||
Deadline string `json:"deadline_at"`
|
||||
Parameters map[string]any `json:"parameters"`
|
||||
}
|
||||
|
||||
// This is the complete journal schema. No command parameters or credentials.
|
||||
type EnrollmentOperation struct {
|
||||
ID string `json:"operation_id"`
|
||||
NodeID string `json:"node_id"`
|
||||
RuntimeID string `json:"runtime_id"`
|
||||
Action string `json:"action"`
|
||||
State string `json:"state"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Result map[string]any `json:"result,omitempty"`
|
||||
Remote bool `json:"remote,omitempty"`
|
||||
Updated int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
type DeviceEnrollment struct {
|
||||
mu sync.Mutex
|
||||
root string
|
||||
nodeID string
|
||||
client *http.Client
|
||||
operations map[string]*EnrollmentOperation
|
||||
}
|
||||
|
||||
func OpenDeviceEnrollment(root, nodeID string) (*DeviceEnrollment, error) {
|
||||
dir := filepath.Join(root, "device-enrollment")
|
||||
if e := os.MkdirAll(dir, 0700); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
d := &DeviceEnrollment{root: dir, nodeID: nodeID, operations: map[string]*EnrollmentOperation{}}
|
||||
d.client = &http.Client{Timeout: 185 * time.Second, Transport: &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
return (&net.Dialer{}).DialContext(ctx, "unix", "/run/mission-core-k1/driver.sock")
|
||||
}}}
|
||||
files, _ := filepath.Glob(filepath.Join(dir, "op_*.json"))
|
||||
for _, path := range files {
|
||||
data, e := os.ReadFile(path)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
var op EnrollmentOperation
|
||||
if json.Unmarshal(data, &op) != nil || !operationID.MatchString(op.ID) {
|
||||
return nil, errors.New("invalid enrollment journal")
|
||||
}
|
||||
if op.State == "running" {
|
||||
op.State = "unknown"
|
||||
op.Error = "БК перезапущен. Обновите состояние K1 перед новой командой."
|
||||
}
|
||||
d.operations[op.ID] = &op
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (d *DeviceEnrollment) call(ctx context.Context, path string, command any) (map[string]any, error) {
|
||||
var body io.Reader
|
||||
method := "GET"
|
||||
if command != nil {
|
||||
data, e := json.Marshal(command)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
body = bytes.NewReader(data)
|
||||
method = "POST"
|
||||
}
|
||||
request, e := http.NewRequestWithContext(ctx, method, "http://k1"+path, body)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("X-Node-Id", d.nodeID)
|
||||
response, e := d.client.Do(request)
|
||||
if e != nil {
|
||||
return nil, errors.New("Служба K1 на БК недоступна.")
|
||||
}
|
||||
defer response.Body.Close()
|
||||
var value map[string]any
|
||||
if json.NewDecoder(io.LimitReader(response.Body, 65536)).Decode(&value) != nil || response.StatusCode != 200 {
|
||||
return nil, errors.New("Действие K1 не подтверждено. Обновите состояние устройства.")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (d *DeviceEnrollment) Status() map[string]any {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
value, e := d.call(ctx, "/status", nil)
|
||||
if e != nil {
|
||||
value = map[string]any{"available": false}
|
||||
}
|
||||
value["node_id"] = d.nodeID
|
||||
value["name"], _ = os.Hostname()
|
||||
value["fresh"] = true
|
||||
return value
|
||||
}
|
||||
|
||||
func (c EnrollmentCommand) valid(nodeID string) bool {
|
||||
deadline, e := time.Parse(time.RFC3339Nano, c.Deadline)
|
||||
if e != nil || !deadline.After(time.Now()) || time.Until(deadline) > 180*time.Second || c.NodeID != nodeID || !operationID.MatchString(c.ID) || c.RuntimeID == "" || len(c.RuntimeID) > 128 {
|
||||
return false
|
||||
}
|
||||
keys := map[string]bool{}
|
||||
switch c.Action {
|
||||
case "scan", "networks":
|
||||
case "connect", "verify":
|
||||
keys = map[string]bool{"device_id": true, "discovery_generation": true, "mode_revision": true}
|
||||
id, ok := c.Parameters["device_id"].(string)
|
||||
if !ok || len(id) < 1 || len(id) > 128 {
|
||||
return false
|
||||
}
|
||||
for _, key := range []string{"discovery_generation", "mode_revision"} {
|
||||
v, ok := c.Parameters[key].(float64)
|
||||
if !ok || v < 0 || v != float64(int64(v)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if c.Action == "connect" {
|
||||
keys["ssid"] = true
|
||||
keys["password"] = true
|
||||
for key, limit := range map[string]int{"ssid": 32, "password": 64} {
|
||||
v, ok := c.Parameters[key].(string)
|
||||
if !ok || len(v) < 1 || len(v) > limit {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
if len(keys) != len(c.Parameters) {
|
||||
return false
|
||||
}
|
||||
for key := range c.Parameters {
|
||||
if !keys[key] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func copyEnrollment(value *EnrollmentOperation) *EnrollmentOperation {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
data, _ := json.Marshal(value)
|
||||
var out EnrollmentOperation
|
||||
_ = json.Unmarshal(data, &out)
|
||||
return &out
|
||||
}
|
||||
|
||||
func (d *DeviceEnrollment) Submit(c EnrollmentCommand, remote bool) (*EnrollmentOperation, error) {
|
||||
if !c.valid(d.nodeID) {
|
||||
return nil, errors.New("Проверьте БК, выбранное устройство и параметры сети.")
|
||||
}
|
||||
d.mu.Lock()
|
||||
if old := d.operations[c.ID]; old != nil {
|
||||
out := copyEnrollment(old)
|
||||
d.mu.Unlock()
|
||||
return out, nil
|
||||
}
|
||||
if len(d.operations) >= 2000 {
|
||||
for id, value := range d.operations {
|
||||
if value.State != "running" && time.Now().Unix()-value.Updated > 86400 {
|
||||
if os.Remove(filepath.Join(d.root, id+".json")) == nil {
|
||||
delete(d.operations, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(d.operations) >= 2000 {
|
||||
d.mu.Unlock()
|
||||
return nil, errors.New("Журнал подключений заполнен. Повторите позже.")
|
||||
}
|
||||
}
|
||||
for _, v := range d.operations {
|
||||
if v.State == "running" {
|
||||
d.mu.Unlock()
|
||||
return nil, errors.New("Дождитесь текущего действия K1.")
|
||||
}
|
||||
}
|
||||
d.mu.Unlock()
|
||||
if d.Status()["runtime_id"] != c.RuntimeID {
|
||||
return nil, errors.New("Сеанс K1 изменился. Обновите устройства.")
|
||||
}
|
||||
d.mu.Lock()
|
||||
// Status can yield; repeat admission under the lock before the durable write.
|
||||
if old := d.operations[c.ID]; old != nil {
|
||||
out := copyEnrollment(old)
|
||||
d.mu.Unlock()
|
||||
return out, nil
|
||||
}
|
||||
for _, v := range d.operations {
|
||||
if v.State == "running" {
|
||||
d.mu.Unlock()
|
||||
return nil, errors.New("Дождитесь текущего действия K1.")
|
||||
}
|
||||
}
|
||||
op := &EnrollmentOperation{ID: c.ID, NodeID: c.NodeID, RuntimeID: c.RuntimeID, Action: c.Action, State: "running", Remote: remote, Updated: time.Now().Unix()}
|
||||
if e := savePrivateJSON(filepath.Join(d.root, c.ID+".json"), op); e != nil {
|
||||
d.mu.Unlock()
|
||||
return nil, e
|
||||
}
|
||||
d.operations[c.ID] = op
|
||||
out := copyEnrollment(op)
|
||||
d.mu.Unlock()
|
||||
go d.execute(c)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (d *DeviceEnrollment) execute(c EnrollmentCommand) {
|
||||
deadline, _ := time.Parse(time.RFC3339Nano, c.Deadline)
|
||||
ctx, cancel := context.WithDeadline(context.Background(), deadline)
|
||||
defer cancel()
|
||||
result, e := d.call(ctx, "/operation", c)
|
||||
// Dropping references is best-effort lifetime reduction, not a claim that
|
||||
// Go can zero every immutable JSON/string copy. No secret is journalled.
|
||||
delete(c.Parameters, "password")
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
op := d.operations[c.ID]
|
||||
op.Updated = time.Now().Unix()
|
||||
op.State = "complete"
|
||||
op.Result = result
|
||||
if e != nil {
|
||||
op.State = "unknown"
|
||||
op.Error = "Действие K1 не подтверждено. Обновите состояние; повторная команда автоматически не отправляется."
|
||||
}
|
||||
if e := savePrivateJSON(filepath.Join(d.root, c.ID+".json"), op); e != nil {
|
||||
op.State = "unknown"
|
||||
op.Error = "Результат не сохранён. Обновите состояние K1."
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DeviceEnrollment) Get(id string) *EnrollmentOperation {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
return copyEnrollment(d.operations[id])
|
||||
}
|
||||
func (d *DeviceEnrollment) RemoteResults() []*EnrollmentOperation {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
out := []*EnrollmentOperation{}
|
||||
for _, op := range d.operations {
|
||||
if op.Remote {
|
||||
out = append(out, copyEnrollment(op))
|
||||
if len(out) == 8 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
func (d *DeviceEnrollment) Acknowledge(ids []string) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
for _, id := range ids {
|
||||
if op := d.operations[id]; op != nil && op.State != "running" {
|
||||
op.Remote = false
|
||||
_ = savePrivateJSON(filepath.Join(d.root, id+".json"), op)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DeviceEnrollment) Routes(mux *http.ServeMux, server *Server) {
|
||||
mux.HandleFunc("GET /api/devices/enrollment", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !server.authorized(w, r) {
|
||||
return
|
||||
}
|
||||
v := d.Status()
|
||||
_, name := server.Store.Public()
|
||||
v["name"] = name
|
||||
reply(w, 200, v)
|
||||
})
|
||||
mux.HandleFunc("POST /api/devices/enrollment/operations", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !server.authorized(w, r) {
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 16384)
|
||||
var c EnrollmentCommand
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
if r.Header.Get("Content-Type") != "application/json" || decoder.Decode(&c) != nil {
|
||||
reply(w, 400, map[string]string{"error": "Некорректный запрос подключения"})
|
||||
return
|
||||
}
|
||||
op, e := d.Submit(c, false)
|
||||
if e != nil {
|
||||
reply(w, 409, map[string]string{"error": e.Error()})
|
||||
return
|
||||
}
|
||||
reply(w, 202, op)
|
||||
})
|
||||
mux.HandleFunc("GET /api/devices/enrollment/operations/{id}", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !server.authorized(w, r) {
|
||||
return
|
||||
}
|
||||
op := d.Get(r.PathValue("id"))
|
||||
if op == nil {
|
||||
reply(w, 200, map[string]string{"state": "unknown", "error": "Результат недоступен. Обновите состояние K1."})
|
||||
return
|
||||
}
|
||||
reply(w, 200, op)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type enrollmentHTTP func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f enrollmentHTTP) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
|
||||
|
||||
func enrollmentRequest() EnrollmentCommand {
|
||||
return EnrollmentCommand{ID: "op_" + strings.Repeat("a", 32), NodeID: "node-test", RuntimeID: "runtime-test", Action: "connect", Deadline: time.Now().Add(time.Minute).UTC().Format(time.RFC3339Nano), Parameters: map[string]any{"device_id": "test-ble", "mode_revision": float64(0), "discovery_generation": float64(1), "ssid": "test-network", "password": token()}}
|
||||
}
|
||||
|
||||
func TestEnrollmentJournalsNoCredentialAndDispatchesOnce(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
d, e := OpenDeviceEnrollment(root, "node-test")
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
var posts atomic.Int32
|
||||
d.client = &http.Client{Transport: enrollmentHTTP(func(r *http.Request) (*http.Response, error) {
|
||||
body := `{"available":true,"runtime_id":"runtime-test"}`
|
||||
if r.Method == "POST" {
|
||||
posts.Add(1)
|
||||
body = `{"available":true,"connected":true}`
|
||||
}
|
||||
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil
|
||||
})}
|
||||
c := enrollmentRequest()
|
||||
secret := c.Parameters["password"].(string)
|
||||
if _, e = d.Submit(c, true); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for d.Get(c.ID).State == "running" && time.Now().Before(deadline) {
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
if d.Get(c.ID).State != "complete" {
|
||||
t.Fatal("operation did not complete")
|
||||
}
|
||||
// A repeated delivery owns no new physical intent, even with a replaced secret.
|
||||
c.Parameters["password"] = token()
|
||||
if _, e = d.Submit(c, true); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if posts.Load() != 1 {
|
||||
t.Fatal("duplicate dispatch")
|
||||
}
|
||||
journal, e := os.ReadFile(filepath.Join(root, "device-enrollment", c.ID+".json"))
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
results, _ := json.Marshal(d.RemoteResults())
|
||||
if bytes.Contains(journal, []byte(secret)) || bytes.Contains(results, []byte(secret)) || bytes.Contains(journal, []byte("parameters")) {
|
||||
t.Fatal("credential/parameters entered journal or results")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrollmentRestartDoesNotReplayRunningIntent(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
d, e := OpenDeviceEnrollment(root, "node-test")
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
c := enrollmentRequest()
|
||||
op := EnrollmentOperation{ID: c.ID, NodeID: c.NodeID, RuntimeID: c.RuntimeID, Action: c.Action, State: "running", Remote: true}
|
||||
if e := savePrivateJSON(filepath.Join(d.root, c.ID+".json"), op); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
restarted, e := OpenDeviceEnrollment(root, "node-test")
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if restarted.Get(c.ID).State != "unknown" {
|
||||
t.Fatal("restart did not fence unknown result")
|
||||
}
|
||||
restarted.client = &http.Client{Transport: enrollmentHTTP(func(*http.Request) (*http.Response, error) { t.Fatal("restart contacted hardware"); return nil, nil })}
|
||||
result, e := restarted.Submit(c, true)
|
||||
if e != nil || result.State != "unknown" {
|
||||
t.Fatal("repeated intent did not retain unknown outcome")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrollmentRejectsAnotherNodeExpiredAndHostMutation(t *testing.T) {
|
||||
c := enrollmentRequest()
|
||||
if !c.valid("node-test") {
|
||||
t.Fatal("valid intent rejected")
|
||||
}
|
||||
if c.valid("another-node") {
|
||||
t.Fatal("wrong node admitted")
|
||||
}
|
||||
c.Parameters["allow_host_wifi_switch"] = true
|
||||
if c.valid("node-test") {
|
||||
t.Fatal("host association admitted")
|
||||
}
|
||||
delete(c.Parameters, "allow_host_wifi_switch")
|
||||
c.Deadline = time.Now().Add(-time.Second).Format(time.RFC3339Nano)
|
||||
if c.valid("node-test") {
|
||||
t.Fatal("expired operation admitted")
|
||||
}
|
||||
}
|
||||
@@ -39,20 +39,21 @@ type PairState struct {
|
||||
Revocations []CoreBinding `json:"revocations,omitempty"`
|
||||
}
|
||||
type Pairing struct {
|
||||
Sensors *Sensors
|
||||
mu sync.Mutex
|
||||
path string
|
||||
store *Store
|
||||
state PairState
|
||||
now func() time.Time
|
||||
inventory func() Inventory
|
||||
version string
|
||||
lastSeen int64
|
||||
connection string
|
||||
listenError string
|
||||
failureWindow int64
|
||||
failures int
|
||||
clients map[string]*http.Client
|
||||
Sensors *Sensors
|
||||
DeviceEnrollment *DeviceEnrollment
|
||||
mu sync.Mutex
|
||||
path string
|
||||
store *Store
|
||||
state PairState
|
||||
now func() time.Time
|
||||
inventory func() Inventory
|
||||
version string
|
||||
lastSeen int64
|
||||
connection string
|
||||
listenError string
|
||||
failureWindow int64
|
||||
failures int
|
||||
clients map[string]*http.Client
|
||||
}
|
||||
|
||||
func OpenPairing(store *Store, dir, version string, inventory func() Inventory) (*Pairing, error) {
|
||||
|
||||
@@ -282,12 +282,28 @@ func (p *Pairing) channel(ctx context.Context) {
|
||||
payload["sensor_state"] = inv
|
||||
payload["sensor_results"] = p.Sensors.RemoteResults()
|
||||
}
|
||||
if p.DeviceEnrollment != nil {
|
||||
payload["device_enrollment"] = p.DeviceEnrollment.Status()
|
||||
payload["enrollment_results"] = p.DeviceEnrollment.RemoteResults()
|
||||
}
|
||||
result, status, e := p.send(ctx, *binding, "/v1/node/heartbeat", payload)
|
||||
p.mu.Lock()
|
||||
if p.state.Phase == "paired" && p.state.Binding != nil && p.state.Binding.BindingID == binding.BindingID {
|
||||
if e == nil && status == 200 {
|
||||
p.connection = "online"
|
||||
p.lastSeen = p.now().Unix()
|
||||
if p.DeviceEnrollment != nil {
|
||||
var ack []string
|
||||
if json.Unmarshal(result["enrollment_acknowledgements"], &ack) == nil {
|
||||
p.DeviceEnrollment.Acknowledge(ack)
|
||||
}
|
||||
var commands []EnrollmentCommand
|
||||
if json.Unmarshal(result["enrollment_commands"], &commands) == nil {
|
||||
for _, c := range commands {
|
||||
_, _ = p.DeviceEnrollment.Submit(c, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
if p.Sensors != nil {
|
||||
var ack []string
|
||||
if json.Unmarshal(result["sensor_acknowledgements"], &ack) == nil {
|
||||
|
||||
@@ -45,19 +45,20 @@ type SensorOperation struct {
|
||||
Updated int64 `json:"updated_at"`
|
||||
}
|
||||
type Sensors struct {
|
||||
events sensorEvents
|
||||
mu sync.Mutex
|
||||
prepareMu sync.Mutex
|
||||
root string
|
||||
nodeID string
|
||||
instance string
|
||||
client *http.Client
|
||||
operations map[string]*SensorOperation
|
||||
names map[string]string
|
||||
initialized map[string]bool
|
||||
events sensorEvents
|
||||
mu sync.Mutex
|
||||
prepareMu sync.Mutex
|
||||
root string
|
||||
nodeID string
|
||||
instance string
|
||||
client *http.Client
|
||||
NetworkDevices *DeviceEnrollment
|
||||
operations map[string]*SensorOperation
|
||||
names map[string]string
|
||||
initialized map[string]bool
|
||||
}
|
||||
|
||||
var sensorID = regexp.MustCompile(`^rsd455_[0-9a-f]{32}$`)
|
||||
var sensorID = regexp.MustCompile(`^(rsd455|k1)_[0-9a-f]{32}$`)
|
||||
var operationID = regexp.MustCompile(`^op_[0-9a-f]{32}$`)
|
||||
|
||||
func OpenSensors(root, nodeID string) (*Sensors, error) {
|
||||
@@ -168,6 +169,32 @@ func (s *Sensors) driver(path string, body any) (map[string]any, error) {
|
||||
func (s *Sensors) Inventory() map[string]any {
|
||||
items := []any{}
|
||||
seen := map[string]bool{}
|
||||
if s.NetworkDevices != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
result, e := s.NetworkDevices.call(ctx, "/inventory", nil)
|
||||
cancel()
|
||||
if e == nil {
|
||||
if found, ok := result["items"].([]any); ok {
|
||||
for _, raw := range found {
|
||||
item, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
id, _ := item["id"].(string)
|
||||
if !strings.HasPrefix(id, "k1_") || !sensorID.MatchString(id) {
|
||||
continue
|
||||
}
|
||||
s.mu.Lock()
|
||||
if name := s.names[id]; name != "" {
|
||||
item["name"] = name
|
||||
}
|
||||
s.mu.Unlock()
|
||||
seen[id] = true
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if result, e := s.driver("/inventory", nil); e == nil {
|
||||
if found, ok := result["items"].([]any); ok {
|
||||
for _, v := range found {
|
||||
@@ -259,6 +286,9 @@ func sensorViewAction(action string) bool {
|
||||
}
|
||||
|
||||
func (s *Sensors) Submit(c SensorCommand, remote bool) (*SensorOperation, error) {
|
||||
if strings.HasPrefix(c.Session.DeviceID, "k1_") && (c.Action == "prepare" || c.Action == "replay") {
|
||||
return nil, errors.New("Эта операция не поддерживается K1.")
|
||||
}
|
||||
if c.APIVersion != SensorSchema || c.Kind != "OperationRequest" || !operationID.MatchString(c.ID) || c.Idempotency != c.ID || !sensorID.MatchString(c.Session.DeviceID) || len(c.Session.SessionID) > 192 {
|
||||
return nil, errors.New("Некорректная команда устройства.")
|
||||
}
|
||||
@@ -344,7 +374,14 @@ func (s *Sensors) execute(c SensorCommand) {
|
||||
}
|
||||
} else {
|
||||
var v map[string]any
|
||||
v, err = s.driver("/operation", c)
|
||||
if strings.HasPrefix(c.Session.DeviceID, "k1_") && s.NetworkDevices != nil {
|
||||
deadline, _ := time.Parse(time.RFC3339Nano, c.Deadline)
|
||||
ctx, cancel := context.WithDeadline(context.Background(), deadline)
|
||||
v, err = s.NetworkDevices.call(ctx, "/sensor-operation", c)
|
||||
cancel()
|
||||
} else {
|
||||
v, err = s.driver("/operation", c)
|
||||
}
|
||||
uncertain = err != nil || v["state"] == "unknown"
|
||||
if err == nil {
|
||||
if v["state"] == "complete" {
|
||||
|
||||
@@ -13,20 +13,21 @@ import (
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
Store *Store
|
||||
Pairing *Pairing
|
||||
Sensors *Sensors
|
||||
Assets fs.FS
|
||||
Origin string
|
||||
Version string
|
||||
Inventory func() Inventory
|
||||
Access *AccessStore
|
||||
Tailscale func() TailscaleStatus
|
||||
Environment func() EnvironmentStatus
|
||||
mu sync.Mutex
|
||||
logins map[string]time.Time
|
||||
sessions map[string]time.Time
|
||||
Now func() time.Time
|
||||
Store *Store
|
||||
Pairing *Pairing
|
||||
Sensors *Sensors
|
||||
DeviceEnrollment *DeviceEnrollment
|
||||
Assets fs.FS
|
||||
Origin string
|
||||
Version string
|
||||
Inventory func() Inventory
|
||||
Access *AccessStore
|
||||
Tailscale func() TailscaleStatus
|
||||
Environment func() EnvironmentStatus
|
||||
mu sync.Mutex
|
||||
logins map[string]time.Time
|
||||
sessions map[string]time.Time
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func token() string {
|
||||
@@ -82,6 +83,9 @@ func (s *Server) Handler() http.Handler {
|
||||
if s.Sensors != nil {
|
||||
s.Sensors.Routes(mux, s)
|
||||
}
|
||||
if s.DeviceEnrollment != nil {
|
||||
s.DeviceEnrollment.Routes(mux, s)
|
||||
}
|
||||
if s.Pairing != nil {
|
||||
s.Pairing.localRoutes(mux, s)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
// Only WLAN discovery. No host association/profile modification authority.
|
||||
polkit.addRule(function(action, subject) {
|
||||
if (subject.user === "mission-core-k1" && action.id === "org.freedesktop.NetworkManager.wifi.scan") {
|
||||
return polkit.Result.YES;
|
||||
}
|
||||
});
|
||||
@@ -11,7 +11,7 @@ import sys
|
||||
from build_deb import build, VERSION, BRAND_SHA256
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DG_COMMIT = "999864e5b0a81555823cfa1ea6e8cf8a417c37f1"
|
||||
DG_COMMIT = "1bdfc6c24072d38cc1068086ea271c444f2524ad"
|
||||
|
||||
|
||||
def guideline_sources():
|
||||
@@ -32,6 +32,8 @@ def provenance():
|
||||
"base_commit": subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=ROOT, text=True).strip(),
|
||||
"design_guideline_commit": DG_COMMIT,
|
||||
"design_guideline_files": guideline_sources(),
|
||||
"shared_sensor_ui_files": {str(p.relative_to(ROOT.parents[1])): hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted((ROOT.parents[1] / "packages/sensor-ui/src").rglob("*")) if p.is_file()},
|
||||
"k1_runtime_files": {str(p.relative_to(ROOT.parents[1])): hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted((ROOT.parents[1] / "src/k1link").rglob("*")) if p.is_file() and p.suffix in (".py", ".json")},
|
||||
"toolchain": json.loads((ROOT / "toolchain.json").read_text()), "files": files}
|
||||
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import tarfile
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
VERSION = "0.6.11"
|
||||
VERSION = "0.7.0"
|
||||
BRAND_SHA256 = "8bfee8ca9f98e0db48d98aae3af4b32493b8593e18b064a0239d513d824182af"
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ Architecture: amd64
|
||||
Maintainer: NODE.DC local build <noreply@example.invalid>
|
||||
Section: admin
|
||||
Priority: optional
|
||||
Depends: adduser, systemd, python3, python3-gi, gir1.2-gtk-3.0, gir1.2-webkit2-4.1, pkexec, polkitd, ca-certificates, hicolor-icon-theme
|
||||
Depends: adduser, systemd, python3, python3-gi, gir1.2-gtk-3.0, gir1.2-webkit2-4.1, pkexec, polkitd, ca-certificates, hicolor-icon-theme, bluez, network-manager, iproute2, ffmpeg
|
||||
Description: Mission Core onboard computer configuration
|
||||
Local graphical setup, host inventory, SSH access and persistent node identity.
|
||||
""".encode()
|
||||
@@ -107,6 +107,29 @@ Description: Mission Core onboard computer configuration
|
||||
files.append(("usr/share/mission-core-node/realsense/" + item["name"], data, 0o644))
|
||||
for path in (ROOT / "sensors").glob("*.py"):
|
||||
files.append(("usr/lib/mission-core-node/sensors/" + path.name, path.read_bytes(), 0o644))
|
||||
for name in ("k1_prepare.py", "k1_bootstrap.py"):
|
||||
files.append(("usr/lib/mission-core-node/" + name, (p / name).read_bytes(), 0o644))
|
||||
files.append(("usr/lib/mission-core-node/install-k1-credential", (p / "install-k1-credential").read_bytes(), 0o755))
|
||||
files.append(("usr/lib/systemd/system/mission-core-k1.service", (p / "mission-core-k1.service").read_bytes(), 0o644))
|
||||
files.append(("usr/share/polkit-1/rules.d/50-mission-core-k1.rules", (p / "50-mission-core-k1.rules").read_bytes(), 0o644))
|
||||
k1_bundle = json.loads((p / "k1-bundle.json").read_text())
|
||||
files.append(("usr/share/mission-core-node/k1/bundle.json", (p / "k1-bundle.json").read_bytes(), 0o644))
|
||||
for item in k1_bundle["wheels"]:
|
||||
data = (ROOT / "build/k1-wheels" / item["name"]).read_bytes()
|
||||
if hashlib.sha256(data).hexdigest() != item["sha256"]:
|
||||
raise ValueError("K1 bundle hash mismatch")
|
||||
files.append(("usr/share/mission-core-node/k1/" + item["name"], data, 0o644))
|
||||
repository = ROOT.parents[1]
|
||||
# Reuse the admitted plugin runtime and the transport-neutral renderer.
|
||||
# No separate Core web service is started on the board.
|
||||
for path in (repository / "src/k1link").rglob("*"):
|
||||
if path.is_file() and path.suffix in (".py", ".json"):
|
||||
files.append(("usr/lib/mission-core-node/k1/src/k1link/" + str(path.relative_to(repository / "src/k1link")), path.read_bytes(), 0o644))
|
||||
for relative in ("plugins/xgrids-k1/profile_loader.py", "plugins/xgrids-k1/plugin.manifest.json",
|
||||
"config/observatory-equipment-models.json",
|
||||
"config/observatory-recorded-capture-profiles.json",
|
||||
"plugins/xgrids-k1/profiles/fw-3.0.2/local-network.v2.json"):
|
||||
files.append(("usr/lib/mission-core-node/k1/" + relative, (repository / relative).read_bytes(), 0o644))
|
||||
sdk = ROOT.parents[1] / "packages/plugin-sdk/python/missioncore_plugin_sdk"
|
||||
for path in sdk.rglob("*.py"):
|
||||
files.append(("usr/lib/mission-core-node/sdk/missioncore_plugin_sdk/" + str(path.relative_to(sdk)), path.read_bytes(), 0o644))
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
"""Engineering build input, never run on an operator board. Exact PyPI hashes only."""
|
||||
|
||||
import hashlib
|
||||
import argparse
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
from urllib.request import urlopen
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
manifest = json.loads((root / "packaging/realsense-bundle.json").read_text())
|
||||
output = root / "build/realsense-wheels"
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--model", choices=("realsense", "k1"), default="realsense")
|
||||
model = parser.parse_args().model
|
||||
manifest = json.loads((root / f"packaging/{model}-bundle.json").read_text())
|
||||
output = root / f"build/{model}-wheels"
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
for item in manifest["wheels"]:
|
||||
target = output / item["name"]
|
||||
@@ -23,8 +28,24 @@ for item in manifest["wheels"]:
|
||||
)
|
||||
if not source["url"].startswith("https://files.pythonhosted.org/"):
|
||||
raise ValueError("Unexpected package origin")
|
||||
with urlopen(source["url"], timeout=120) as response:
|
||||
data = response.read(item["bytes"] + 1)
|
||||
partial = target.with_suffix(target.suffix + ".partial")
|
||||
for attempt in range(4):
|
||||
offset = partial.stat().st_size if partial.exists() else 0
|
||||
request = Request(source["url"], headers={"Range": f"bytes={offset}-"} if offset else {})
|
||||
try:
|
||||
with urlopen(request, timeout=60) as response:
|
||||
if offset and response.status != 206:
|
||||
raise RuntimeError("Package server did not honor resume range")
|
||||
with partial.open("ab" if offset else "wb") as stream:
|
||||
while chunk := response.read(1024 * 1024):
|
||||
stream.write(chunk)
|
||||
break
|
||||
except (OSError, TimeoutError):
|
||||
if attempt == 3:
|
||||
raise
|
||||
time.sleep(2)
|
||||
data = partial.read_bytes()
|
||||
if len(data) != item["bytes"] or hashlib.sha256(data).hexdigest() != item["sha256"]:
|
||||
raise ValueError("Driver checksum mismatch")
|
||||
target.write_bytes(data)
|
||||
partial.replace(target)
|
||||
print(json.dumps({"model": model, "wheel": item["name"], "bytes": len(data)}), flush=True)
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/python3 -I
|
||||
"""Administrator-only import of the exact application key from protected stdin."""
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
if os.geteuid() != 0 or len(sys.argv) != 1:
|
||||
raise SystemExit("Root stdin import required")
|
||||
secret = bytearray(sys.stdin.buffer.read(1025).strip())
|
||||
try:
|
||||
if len(secret) != 36 or any(v < 33 or v > 126 for v in secret):
|
||||
raise SystemExit("Credential does not match the reviewed K1 profile")
|
||||
root = Path("/etc/credstore.encrypted")
|
||||
root.mkdir(mode=0o700, exist_ok=True)
|
||||
if root.is_symlink() or root.stat().st_uid != 0 or root.stat().st_mode & 0o022:
|
||||
raise SystemExit("Unsafe credential store")
|
||||
path = root / "k1-application"
|
||||
if path.is_symlink() or path.exists():
|
||||
raise SystemExit("K1 credential already installed; explicit rotation required")
|
||||
with tempfile.TemporaryDirectory(prefix=".k1-", dir=root) as directory:
|
||||
staged = Path(directory) / "encrypted"
|
||||
completed = subprocess.run(
|
||||
["/usr/bin/systemd-creds", "encrypt", "--name=k1-application", "--with-key=host", "-", str(staged)],
|
||||
input=secret, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=30,
|
||||
)
|
||||
if completed.returncode:
|
||||
raise SystemExit("K1 credential import failed")
|
||||
staged.chmod(0o600)
|
||||
with staged.open("rb") as stream:
|
||||
os.fsync(stream.fileno())
|
||||
# Atomic publication without overwriting a concurrently installed key.
|
||||
os.link(staged, path)
|
||||
finally:
|
||||
secret[:] = b"\0" * len(secret)
|
||||
@@ -0,0 +1,265 @@
|
||||
{
|
||||
"schema": "missioncore.node.driver-bundle/v1",
|
||||
"model_id": "xgrids.k1",
|
||||
"revision": "c7ed0bba39f757afdac176a8",
|
||||
"python": "3.12",
|
||||
"platform": "linux-amd64",
|
||||
"lock_sha256": "551c8ccdc44bc3724328dd1e316c81d20e63d2e4dcd97148377d6cacef479246",
|
||||
"wheels": [
|
||||
{
|
||||
"name": "aioice-0.10.2-py3-none-any.whl",
|
||||
"sha256": "14911c15ab12d096dd14d372ebb4aecbb7420b52c9b76fdfcf54375dec17fcbf",
|
||||
"bytes": 24875
|
||||
},
|
||||
{
|
||||
"name": "aiortc-1.14.0-py3-none-any.whl",
|
||||
"sha256": "4b244d7e482f4e1f67e685b3468269628eca1ec91fa5b329ab517738cfca086e",
|
||||
"bytes": 93183
|
||||
},
|
||||
{
|
||||
"name": "annotated_doc-0.0.4-py3-none-any.whl",
|
||||
"sha256": "571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320",
|
||||
"bytes": 5303
|
||||
},
|
||||
{
|
||||
"name": "annotated_types-0.7.0-py3-none-any.whl",
|
||||
"sha256": "1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53",
|
||||
"bytes": 13643
|
||||
},
|
||||
{
|
||||
"name": "anyio-4.14.2-py3-none-any.whl",
|
||||
"sha256": "9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494",
|
||||
"bytes": 125813
|
||||
},
|
||||
{
|
||||
"name": "attrs-26.1.0-py3-none-any.whl",
|
||||
"sha256": "c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309",
|
||||
"bytes": 67548
|
||||
},
|
||||
{
|
||||
"name": "av-16.1.0-cp312-cp312-manylinux_2_28_x86_64.whl",
|
||||
"sha256": "7ae547f6d5fa31763f73900d43901e8c5fa6367bb9a9840978d57b5a7ae14ed2",
|
||||
"bytes": 41174337
|
||||
},
|
||||
{
|
||||
"name": "bleak-3.0.2-py3-none-any.whl",
|
||||
"sha256": "39092feb9e83f1df5ad2f88e837723c7211c982ce9e9cda6235104bc2ebe0d0d",
|
||||
"bytes": 146490
|
||||
},
|
||||
{
|
||||
"name": "certifi-2026.7.22-py3-none-any.whl",
|
||||
"sha256": "62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775",
|
||||
"bytes": 136983
|
||||
},
|
||||
{
|
||||
"name": "cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl",
|
||||
"sha256": "c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf",
|
||||
"bytes": 221822
|
||||
},
|
||||
{
|
||||
"name": "click-8.4.2-py3-none-any.whl",
|
||||
"sha256": "e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76",
|
||||
"bytes": 119243
|
||||
},
|
||||
{
|
||||
"name": "cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl",
|
||||
"sha256": "42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b",
|
||||
"bytes": 4459756
|
||||
},
|
||||
{
|
||||
"name": "dbus_fast-5.0.22-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl",
|
||||
"sha256": "4ffcf16034f71a801bd2108aeffb6337d104c9459e8b1a218d16a917c8a2d2e9",
|
||||
"bytes": 852687
|
||||
},
|
||||
{
|
||||
"name": "dnspython-2.8.0-py3-none-any.whl",
|
||||
"sha256": "01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af",
|
||||
"bytes": 331094
|
||||
},
|
||||
{
|
||||
"name": "fastapi-0.139.0-py3-none-any.whl",
|
||||
"sha256": "cf15e1e9e667ddb0ad63811e60bd11390d1aac838ca4a7a23f421807b2308189",
|
||||
"bytes": 130339
|
||||
},
|
||||
{
|
||||
"name": "foxglove_sdk-0.25.3-cp310-abi3-manylinux_2_28_x86_64.whl",
|
||||
"sha256": "bcc894b88188d8169973cfbb1370300f671760adea9d6e9447e5a03b2289527d",
|
||||
"bytes": 19220466
|
||||
},
|
||||
{
|
||||
"name": "google_crc32c-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl",
|
||||
"sha256": "14f87e04d613dfa218d6135e81b78272c3b904e2a7053b841481b38a7d901411",
|
||||
"bytes": 33364
|
||||
},
|
||||
{
|
||||
"name": "h11-0.16.0-py3-none-any.whl",
|
||||
"sha256": "63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86",
|
||||
"bytes": 37515
|
||||
},
|
||||
{
|
||||
"name": "httpcore-1.0.9-py3-none-any.whl",
|
||||
"sha256": "2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55",
|
||||
"bytes": 78784
|
||||
},
|
||||
{
|
||||
"name": "httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl",
|
||||
"sha256": "b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2",
|
||||
"bytes": 523851
|
||||
},
|
||||
{
|
||||
"name": "httpx-0.28.1-py3-none-any.whl",
|
||||
"sha256": "d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad",
|
||||
"bytes": 73517
|
||||
},
|
||||
{
|
||||
"name": "idna-3.18-py3-none-any.whl",
|
||||
"sha256": "7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2",
|
||||
"bytes": 65455
|
||||
},
|
||||
{
|
||||
"name": "ifaddr-0.2.0-py3-none-any.whl",
|
||||
"sha256": "085e0305cfe6f16ab12d72e2024030f5d52674afad6911bb1eee207177b8a748",
|
||||
"bytes": 12314
|
||||
},
|
||||
{
|
||||
"name": "lz4-4.4.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl",
|
||||
"sha256": "24092635f47538b392c4eaeff14c7270d2c8e806bf4be2a6446a378591c5e69e",
|
||||
"bytes": 1368249
|
||||
},
|
||||
{
|
||||
"name": "markdown_it_py-4.2.0-py3-none-any.whl",
|
||||
"sha256": "9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a",
|
||||
"bytes": 91687
|
||||
},
|
||||
{
|
||||
"name": "mdurl-0.1.2-py3-none-any.whl",
|
||||
"sha256": "84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8",
|
||||
"bytes": 9979
|
||||
},
|
||||
{
|
||||
"name": "numpy-2.5.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl",
|
||||
"sha256": "59fda5e192b570217ec2580c96f00e9a7e12ef6866a900eb089b62c1a32545ca",
|
||||
"bytes": 16672469
|
||||
},
|
||||
{
|
||||
"name": "paho_mqtt-2.1.0-py3-none-any.whl",
|
||||
"sha256": "6db9ba9b34ed5bc6b6e3812718c7e06e2fd7444540df2455d2c51bd58808feee",
|
||||
"bytes": 67219
|
||||
},
|
||||
{
|
||||
"name": "pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl",
|
||||
"sha256": "78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91",
|
||||
"bytes": 6940830
|
||||
},
|
||||
{
|
||||
"name": "psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl",
|
||||
"sha256": "076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9",
|
||||
"bytes": 155560
|
||||
},
|
||||
{
|
||||
"name": "pyarrow-25.0.0-cp312-cp312-manylinux_2_28_x86_64.whl",
|
||||
"sha256": "5d1dbf24e151042f2fa3c129563f65d66674128868496fb008c4272b16bdf778",
|
||||
"bytes": 50088993
|
||||
},
|
||||
{
|
||||
"name": "pycparser-3.0-py3-none-any.whl",
|
||||
"sha256": "b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992",
|
||||
"bytes": 48172
|
||||
},
|
||||
{
|
||||
"name": "pydantic-2.13.4-py3-none-any.whl",
|
||||
"sha256": "45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba",
|
||||
"bytes": 472262
|
||||
},
|
||||
{
|
||||
"name": "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl",
|
||||
"sha256": "926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce",
|
||||
"bytes": 2094516
|
||||
},
|
||||
{
|
||||
"name": "pyee-14.0.0-py3-none-any.whl",
|
||||
"sha256": "3ac2d3229a9677f7de2c33d7f52fe25b638a46b19c413fea2edc8c6d0a644e4d",
|
||||
"bytes": 15553
|
||||
},
|
||||
{
|
||||
"name": "pygments-2.20.0-py3-none-any.whl",
|
||||
"sha256": "81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176",
|
||||
"bytes": 1231151
|
||||
},
|
||||
{
|
||||
"name": "pylibsrtp-1.0.0-cp310-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl",
|
||||
"sha256": "293c9f2ac21a2bd689c477603a1aa235d85cf252160e6715f0101e42a43cbedc",
|
||||
"bytes": 2434534
|
||||
},
|
||||
{
|
||||
"name": "pyopenssl-26.2.0-py3-none-any.whl",
|
||||
"sha256": "4f9d971bc5298b8bc1fab282803da04bf000c755d4ad9d99b52de2569ca19a70",
|
||||
"bytes": 55823
|
||||
},
|
||||
{
|
||||
"name": "python_dotenv-1.2.2-py3-none-any.whl",
|
||||
"sha256": "1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a",
|
||||
"bytes": 22101
|
||||
},
|
||||
{
|
||||
"name": "pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl",
|
||||
"sha256": "ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc",
|
||||
"bytes": 807870
|
||||
},
|
||||
{
|
||||
"name": "rerun_sdk-0.36.3-cp310-abi3-manylinux_2_28_x86_64.whl",
|
||||
"sha256": "287059b7154bf3881f5b32035f5772d0556d55a0a894650fb74a2605fb39afbe",
|
||||
"bytes": 163018185
|
||||
},
|
||||
{
|
||||
"name": "rich-14.3.4-py3-none-any.whl",
|
||||
"sha256": "07e7adb4690f68864777b1450859253bed81a99a31ac321ac1817b2313558952",
|
||||
"bytes": 310480
|
||||
},
|
||||
{
|
||||
"name": "shellingham-1.5.4-py2.py3-none-any.whl",
|
||||
"sha256": "7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686",
|
||||
"bytes": 9755
|
||||
},
|
||||
{
|
||||
"name": "starlette-1.3.1-py3-none-any.whl",
|
||||
"sha256": "c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6",
|
||||
"bytes": 73632
|
||||
},
|
||||
{
|
||||
"name": "typer-0.26.8-py3-none-any.whl",
|
||||
"sha256": "3512ca79ac5c11113414b36e80281b872884477722440691c89d1112e321a49c",
|
||||
"bytes": 122564
|
||||
},
|
||||
{
|
||||
"name": "typing_extensions-4.16.0-py3-none-any.whl",
|
||||
"sha256": "481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8",
|
||||
"bytes": 45571
|
||||
},
|
||||
{
|
||||
"name": "typing_inspection-0.4.2-py3-none-any.whl",
|
||||
"sha256": "4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7",
|
||||
"bytes": 14611
|
||||
},
|
||||
{
|
||||
"name": "uvicorn-0.51.0-py3-none-any.whl",
|
||||
"sha256": "5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b",
|
||||
"bytes": 73219
|
||||
},
|
||||
{
|
||||
"name": "uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl",
|
||||
"sha256": "7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4",
|
||||
"bytes": 4426307
|
||||
},
|
||||
{
|
||||
"name": "watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl",
|
||||
"sha256": "e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5",
|
||||
"bytes": 456398
|
||||
},
|
||||
{
|
||||
"name": "websockets-16.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl",
|
||||
"sha256": "35f41979c8623df9bd30d949d82010a8fda5c56ff12cd8508a5b7272b6d4b53a",
|
||||
"bytes": 187345
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Fixed root-owned import path; no user site, environment path or import hooks."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
root = Path("/var/lib/mission-core-k1-runtime")
|
||||
reference = root / "active.path"
|
||||
runtime = Path(reference.read_text().strip())
|
||||
if (
|
||||
reference.is_symlink()
|
||||
or runtime.is_symlink()
|
||||
or runtime.parent != root
|
||||
or not runtime.name.isalnum()
|
||||
or runtime.stat().st_uid != 0
|
||||
or runtime.stat().st_mode & 0o022
|
||||
):
|
||||
raise RuntimeError("Unsafe K1 runtime")
|
||||
sys.path[:0] = [
|
||||
str(runtime), str(runtime / "rerun_sdk"),
|
||||
"/usr/lib/mission-core-node/k1/src", "/usr/lib/mission-core-node/sdk",
|
||||
]
|
||||
os.environ["MISSIONCORE_DATA_DIR"] = "/var/lib/mission-core-k1"
|
||||
from k1link.device_plugins.xgrids_k1.node_bridge import main
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Install only the bundled, hash-pinned Ubuntu K1 runtime; no network I/O."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
SHARE = Path("/usr/share/mission-core-node/k1")
|
||||
ROOT = Path("/var/lib/mission-core-k1-runtime")
|
||||
|
||||
|
||||
def members(archive):
|
||||
for info in archive.infolist():
|
||||
path = PurePosixPath(info.filename)
|
||||
if (
|
||||
path.is_absolute()
|
||||
or ".." in path.parts
|
||||
or (info.external_attr >> 16) & 0o170000 == 0o120000
|
||||
or ".data" in path.parts
|
||||
):
|
||||
raise RuntimeError("Unsafe K1 runtime archive")
|
||||
# Rerun's pinned wheel declares this one static package directory.
|
||||
# We do not execute .pth files; bootstrap adds the exact directory.
|
||||
if info.filename.endswith(".pth") and not (
|
||||
info.filename == "rerun_sdk.pth" and archive.read(info) == b"rerun_sdk\n"
|
||||
):
|
||||
raise RuntimeError("Unreviewed K1 Python path hook")
|
||||
# Distribution script/data relocation must be handled deliberately,
|
||||
# never interpreted as an install hook by the operator's Python.
|
||||
if any(part.endswith(".data") for part in path.parts):
|
||||
raise RuntimeError("K1 wheel requires unsupported relocation")
|
||||
yield info
|
||||
|
||||
|
||||
def prepare():
|
||||
if os.geteuid() != 0 or os.uname().machine != "x86_64" or sys.version_info[:2] != (3, 12):
|
||||
raise RuntimeError("K1 runtime requires privileged Ubuntu amd64 Python 3.12 installation")
|
||||
release = Path("/etc/os-release").read_text()
|
||||
if "ID=ubuntu" not in release or 'VERSION_ID="24.04"' not in release:
|
||||
raise RuntimeError("K1 runtime requires Ubuntu 24.04")
|
||||
manifest = json.loads((SHARE / "bundle.json").read_text())
|
||||
revision = manifest["revision"]
|
||||
if not revision.isalnum():
|
||||
raise RuntimeError("Invalid K1 runtime revision")
|
||||
ROOT.mkdir(mode=0o755, exist_ok=True)
|
||||
if ROOT.is_symlink() or ROOT.stat().st_uid != 0 or ROOT.stat().st_mode & 0o022:
|
||||
raise RuntimeError("Unsafe K1 runtime root")
|
||||
target = ROOT / revision
|
||||
if target.is_symlink() or (ROOT / "active.path").is_symlink():
|
||||
raise RuntimeError("Unsafe K1 runtime reference")
|
||||
for item in manifest["wheels"]:
|
||||
path = SHARE / item["name"]
|
||||
if (
|
||||
path.name != item["name"]
|
||||
or path.is_symlink()
|
||||
or hashlib.sha256(path.read_bytes()).hexdigest() != item["sha256"]
|
||||
):
|
||||
raise RuntimeError("K1 runtime checksum mismatch")
|
||||
if not target.exists():
|
||||
stage = Path(tempfile.mkdtemp(prefix=".k1-", dir=ROOT))
|
||||
try:
|
||||
for item in manifest["wheels"]:
|
||||
with zipfile.ZipFile(SHARE / item["name"]) as archive:
|
||||
archive.extractall(stage, members=members(archive))
|
||||
for path in stage.rglob("*"):
|
||||
path.chmod(0o755 if path.is_dir() else 0o644)
|
||||
stage.chmod(0o755)
|
||||
stage.rename(target)
|
||||
finally:
|
||||
if stage.exists():
|
||||
shutil.rmtree(stage)
|
||||
for item in manifest["wheels"]:
|
||||
with zipfile.ZipFile(SHARE / item["name"]) as archive:
|
||||
for info in members(archive):
|
||||
path = target / info.filename
|
||||
if path.is_symlink() or (
|
||||
not info.is_dir() and path.read_bytes() != archive.read(info)
|
||||
):
|
||||
raise RuntimeError("Installed K1 runtime differs from bundled wheel")
|
||||
fd, name = tempfile.mkstemp(prefix=".active-", dir=ROOT)
|
||||
with os.fdopen(fd, "w") as stream:
|
||||
os.fchmod(stream.fileno(), 0o644)
|
||||
stream.write(str(target))
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(name, ROOT / "active.path")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
prepare()
|
||||
@@ -0,0 +1,38 @@
|
||||
[Unit]
|
||||
Description=Mission Core Node K1 Bridge and acquisition
|
||||
After=bluetooth.service NetworkManager.service
|
||||
Wants=bluetooth.service NetworkManager.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=mission-core-k1
|
||||
Group=mission-core-node
|
||||
SupplementaryGroups=bluetooth
|
||||
ExecStart=/usr/bin/python3 -I /usr/lib/mission-core-node/k1_bootstrap.py
|
||||
StateDirectory=mission-core-k1
|
||||
StateDirectoryMode=0700
|
||||
RuntimeDirectory=mission-core-k1
|
||||
RuntimeDirectoryMode=0750
|
||||
LoadCredentialEncrypted=k1-application
|
||||
UMask=0007
|
||||
Environment=OMP_NUM_THREADS=2 OPENBLAS_NUM_THREADS=2
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
PrivateDevices=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectControlGroups=yes
|
||||
RestrictSUIDSGID=yes
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
|
||||
CapabilityBoundingSet=
|
||||
LockPersonality=yes
|
||||
TasksMax=128
|
||||
MemoryMax=2G
|
||||
LimitNOFILE=2048
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -8,6 +8,10 @@ case "$1" in
|
||||
if ! getent passwd mission-core-sensors >/dev/null; then
|
||||
adduser --system --group --home /var/lib/mission-core-sensors --no-create-home --disabled-login mission-core-sensors
|
||||
fi
|
||||
if ! getent passwd mission-core-k1 >/dev/null; then
|
||||
adduser --system --home /var/lib/mission-core-k1 --no-create-home --disabled-login --ingroup mission-core-node mission-core-k1
|
||||
fi
|
||||
/usr/bin/python3 -I /usr/lib/mission-core-node/k1_prepare.py
|
||||
# Only bootstrap required to open the GUI. Operational configuration is a
|
||||
# versioned job started by «Настройка окружения → Сконфигурировать».
|
||||
if [ -d /run/systemd/system ]; then
|
||||
@@ -15,6 +19,8 @@ case "$1" in
|
||||
systemctl enable mission-core-node.service
|
||||
systemctl restart mission-core-node.service
|
||||
systemctl try-restart mission-core-realsense.service
|
||||
systemctl enable mission-core-k1.service
|
||||
systemctl restart mission-core-k1.service
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -8,6 +8,12 @@ if [ "$1" = install ] || [ "$1" = upgrade ]; then
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
if [ -S /run/mission-core-k1/driver.sock ]; then
|
||||
if ! /usr/bin/python3 -I -c 'import http.client,json,socket; c=http.client.HTTPConnection("k1",timeout=5); c.sock=socket.socket(socket.AF_UNIX); c.sock.settimeout(5); c.sock.connect("/run/mission-core-k1/driver.sock"); c.request("GET","/prepare-safe"); r=c.getresponse(); assert r.status==200 and json.load(r).get("safe") is True'; then
|
||||
echo "Mission Core Node: завершите подключение или запись K1 перед обновлением." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
mc_node_device_job=$(systemctl show --property=ActiveState --value mission-core-node-realsense-prepare.service 2>/dev/null || true)
|
||||
case "$mc_node_device_job" in
|
||||
active|activating) echo "Mission Core Node: дождитесь завершения подготовки устройства." >&2; exit 1 ;;
|
||||
@@ -20,6 +26,11 @@ if [ "$1" = install ] || [ "$1" = upgrade ]; then
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
# End the admitted idle worker before dpkg replaces its Python modules.
|
||||
# New UI commands now fail unavailable instead of racing the package copy.
|
||||
if [ -f /usr/lib/systemd/system/mission-core-k1.service ]; then
|
||||
systemctl stop mission-core-k1.service
|
||||
fi
|
||||
fi
|
||||
. /etc/os-release
|
||||
if [ "${ID:-}" != ubuntu ] || [ "${VERSION_ID:-}" != 24.04 ]; then
|
||||
|
||||
@@ -7,6 +7,12 @@ if [ -d /run/systemd/system ]; then
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
if [ -S /run/mission-core-k1/driver.sock ]; then
|
||||
if ! /usr/bin/python3 -I -c 'import http.client,json,socket; c=http.client.HTTPConnection("k1",timeout=5); c.sock=socket.socket(socket.AF_UNIX); c.sock.settimeout(5); c.sock.connect("/run/mission-core-k1/driver.sock"); c.request("GET","/prepare-safe"); r=c.getresponse(); assert r.status==200 and json.load(r).get("safe") is True'; then
|
||||
echo "Mission Core Node: завершите подключение или запись K1 перед обновлением." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
mc_node_device_job=$(systemctl show --property=ActiveState --value mission-core-node-realsense-prepare.service 2>/dev/null || true)
|
||||
case "$mc_node_device_job" in
|
||||
active|activating) echo "Mission Core Node: дождитесь завершения подготовки устройства." >&2; exit 1 ;;
|
||||
@@ -40,6 +46,8 @@ case "$1" in
|
||||
/usr/sbin/sshd -t
|
||||
systemctl try-reload-or-restart ssh.service
|
||||
fi
|
||||
systemctl stop mission-core-k1.service
|
||||
systemctl disable mission-core-k1.service || true
|
||||
systemctl stop mission-core-realsense.service
|
||||
systemctl disable mission-core-realsense.service || true
|
||||
systemctl stop mission-core-node.service
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Resolve Linux wheel inputs from the frozen monorepo lock, without downloading."""
|
||||
import hashlib
|
||||
import json
|
||||
import subprocess
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
from packaging.markers import default_environment
|
||||
from packaging.requirements import Requirement
|
||||
from packaging.tags import compatible_tags, cpython_tags
|
||||
from packaging.utils import canonicalize_name, parse_wheel_filename
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
REPOSITORY = ROOT.parents[1]
|
||||
|
||||
|
||||
def resolve():
|
||||
requirements = ROOT / "build/k1-requirements.txt"
|
||||
requirements.parent.mkdir(exist_ok=True)
|
||||
subprocess.run(["uv", "export", "--frozen", "--extra", "node-device-media", "--no-dev",
|
||||
"--no-emit-project", "--no-emit-package", "missioncore-plugin-sdk", "--no-hashes",
|
||||
"--output-file", str(requirements)], cwd=REPOSITORY, check=True, stdout=subprocess.DEVNULL)
|
||||
environment = default_environment()
|
||||
environment.update(sys_platform="linux", platform_system="Linux", platform_machine="x86_64",
|
||||
python_version="3.12", python_full_version="3.12.3", implementation_name="cpython",
|
||||
platform_python_implementation="CPython")
|
||||
platforms = [f"manylinux_2_{n}_x86_64" for n in range(39, 16, -1)] + ["manylinux2014_x86_64", "linux_x86_64"]
|
||||
tags = list(cpython_tags((3, 12), platforms=platforms)) + list(compatible_tags((3, 12), interpreter="cp312", platforms=platforms))
|
||||
ranks = {tag: i for i, tag in enumerate(tags)}
|
||||
lock_data = (REPOSITORY / "uv.lock").read_bytes()
|
||||
lock = tomllib.loads(lock_data.decode())
|
||||
items = []
|
||||
for line in requirements.read_text().splitlines():
|
||||
if not line or line.lstrip().startswith("#"):
|
||||
continue
|
||||
requirement = Requirement(line)
|
||||
if requirement.marker and not requirement.marker.evaluate(environment):
|
||||
continue
|
||||
name = canonicalize_name(requirement.name)
|
||||
package = next(v for v in lock["package"] if canonicalize_name(v["name"]) == name and v["version"] in requirement.specifier)
|
||||
candidates = []
|
||||
for wheel in package.get("wheels", []):
|
||||
filename = wheel["url"].split("/")[-1]
|
||||
_, _, _, wheel_tags = parse_wheel_filename(filename)
|
||||
matches = [ranks[tag] for tag in wheel_tags if tag in ranks]
|
||||
if matches:
|
||||
candidates.append((min(matches), filename, wheel))
|
||||
if not candidates:
|
||||
raise RuntimeError("No reviewed Linux wheel: " + name)
|
||||
_, filename, wheel = min(candidates)
|
||||
items.append({"name": filename, "sha256": wheel["hash"].removeprefix("sha256:"), "bytes": wheel["size"]})
|
||||
revision = hashlib.sha256(json.dumps(items, sort_keys=True).encode()).hexdigest()[:24]
|
||||
manifest = {"schema": "missioncore.node.driver-bundle/v1", "model_id": "xgrids.k1", "revision": revision,
|
||||
"python": "3.12", "platform": "linux-amd64", "lock_sha256": hashlib.sha256(lock_data).hexdigest(), "wheels": items}
|
||||
(ROOT / "packaging/k1-bundle.json").write_text(json.dumps(manifest, indent=2) + "\n")
|
||||
print(json.dumps({"wheels": len(items), "bytes": sum(v["bytes"] for v in items), "revision": revision}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
resolve()
|
||||
Generated
+19
-1
@@ -11,6 +11,7 @@
|
||||
"@nodedc/tokens": "file:../../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
|
||||
"@nodedc/ui-core": "file:../../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
|
||||
"@nodedc/ui-react": "file:../../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
|
||||
"@rerun-io/web-viewer": "0.36.3",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0"
|
||||
},
|
||||
@@ -18,7 +19,8 @@
|
||||
"@types/react": "^19.1.0",
|
||||
"@types/react-dom": "^19.1.0",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^7.0.0"
|
||||
"vite": "^7.0.0",
|
||||
"vite-plugin-wasm": "^3.6.0"
|
||||
}
|
||||
},
|
||||
"../../../../NODEDC_DESIGN_GUIDELINE/packages/tokens": {
|
||||
@@ -524,6 +526,12 @@
|
||||
"resolved": "../../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@rerun-io/web-viewer": {
|
||||
"version": "0.36.3",
|
||||
"resolved": "https://registry.npmjs.org/@rerun-io/web-viewer/-/web-viewer-0.36.3.tgz",
|
||||
"integrity": "sha512-LMGnsxRmY5UwiGras2dZrMnEYkow5Xr4v+1hAUSspXWPPiilMqoz9G77jo8Ps/deAaX81TnOq123DFO8iX/Ulw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.63.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz",
|
||||
@@ -1239,6 +1247,16 @@
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vite-plugin-wasm": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/vite-plugin-wasm/-/vite-plugin-wasm-3.6.0.tgz",
|
||||
"integrity": "sha512-mL/QPziiIA4RAA6DkaZZzOstdwbW5jO4Vz7Zenj0wieKWBlNvIvX5L5ljum9lcUX0ShNfBgCNLKTjNkRVVqcsw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"vite": "^2 || ^3 || ^4 || ^5 || ^6 || ^7 || ^8"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"build": "tsc --noEmit && vite build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@rerun-io/web-viewer": "0.36.3",
|
||||
"@nodedc/ui-react": "file:../../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
|
||||
"@nodedc/ui-core": "file:../../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
|
||||
"@nodedc/tokens": "file:../../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
|
||||
@@ -16,6 +17,7 @@
|
||||
"react-dom": "19.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite-plugin-wasm": "^3.6.0",
|
||||
"@types/react": "^19.1.0",
|
||||
"@types/react-dom": "^19.1.0",
|
||||
"typescript": "^5.8.3",
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Живой просмотр K1</title>
|
||||
<style>
|
||||
html, body, #viewer { width: 100%; height: 100%; margin: 0; overflow: hidden; }
|
||||
canvas { display: block; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="viewer"></div>
|
||||
<script type="module" src="/src/rerunRuntime.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,5 +1,6 @@
|
||||
import {createIsolatedRerunHost} from '../../../control-station/src/components/rerun/isolatedRerunHost';
|
||||
import {SensorWorkspace} from '../../../../packages/sensor-ui/src/SensorWorkspace';
|
||||
import type {SensorTransport} from '../../../../packages/sensor-ui/src/contracts';
|
||||
import {request} from './api';
|
||||
const transport:SensorTransport={subscribe:(receive,unavailable)=>{const events=new EventSource('/api/devices/events');events.onmessage=e=>{try{receive(JSON.parse(e.data));}catch{unavailable();}};events.onerror=unavailable;return()=>events.close();},inventory:()=>request('/api/devices'),submit:value=>request('/api/devices/operations','POST',value),operation:id=>request('/api/devices/operations/'+encodeURIComponent(id))};
|
||||
export function NodeSensors(){return <SensorWorkspace transport={transport}/>;}
|
||||
const transport:SensorTransport={enrollment:{state:()=>request("/api/devices/enrollment"),submit:value=>request("/api/devices/enrollment/operations","POST",value),operation:id=>request("/api/devices/enrollment/operations/"+encodeURIComponent(id))},subscribe:(receive,unavailable)=>{const events=new EventSource('/api/devices/events');events.onmessage=e=>{try{receive(JSON.parse(e.data));}catch{unavailable();}};events.onerror=unavailable;return()=>events.close();},inventory:()=>request('/api/devices'),submit:value=>request('/api/devices/operations','POST',value),operation:id=>request('/api/devices/operations/'+encodeURIComponent(id))};
|
||||
export function NodeSensors(){return <SensorWorkspace createRerunHost={createIsolatedRerunHost} transport={transport}/>;}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
import "../../../control-station/src/components/rerun/isolatedRerunEntry";
|
||||
@@ -1,9 +1,17 @@
|
||||
import { defineConfig } from "vite";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import wasm from "vite-plugin-wasm";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [wasm()],
|
||||
// Shared sensor TSX lives outside this app tsconfig; use the same JSX runtime.
|
||||
esbuild: { jsx: "automatic" },
|
||||
// Design Guideline packages are linked during development. Their own React
|
||||
// must never become a second hook dispatcher in the portable production bundle.
|
||||
resolve: { dedupe: ["react", "react-dom", "@nodedc/ui-react"] },
|
||||
optimizeDeps: { exclude: ["@rerun-io/web-viewer"] },
|
||||
build: { target: "esnext", rollupOptions: { input: {
|
||||
app: fileURLToPath(new URL("./index.html", import.meta.url)),
|
||||
rerun: fileURLToPath(new URL("./rerun-runtime.html", import.meta.url)),
|
||||
} } },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user