feat(k1): stabilize LAB bridge and isolate onboard device integration

This commit is contained in:
DCCONSTRUCTIONS
2026-09-07 10:31:33 +03:00
parent 020a878915
commit 63ea2bed67
115 changed files with 13700 additions and 988 deletions
+6
View File
@@ -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")
}
}
+15 -14
View File
@@ -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 {
+49 -12
View File
@@ -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" {
+18 -14
View File
@@ -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;
}
});
+4 -1
View File
@@ -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,9 @@ 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_frontend_files": {str(p.relative_to(ROOT.parents[1])): hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted((ROOT.parents[1] / "plugins/xgrids-k1/frontend/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}
+25 -2
View File
@@ -13,7 +13,7 @@ import tarfile
ROOT = Path(__file__).resolve().parents[1]
VERSION = "0.6.11"
VERSION = "0.7.1"
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)
+265
View File
@@ -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
}
]
}
+26
View File
@@ -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()
+94
View File
@@ -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
+6
View File
@@ -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
+11
View File
@@ -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
+8
View File
@@ -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()
+19 -1
View File
@@ -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"
}
}
}
}
+2
View File
@@ -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",
+16
View File
@@ -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>
+4 -2
View File
@@ -1,5 +1,7 @@
import {xgridsK1SensorUi,K1EnrollmentWindow} from '../../../../plugins/xgrids-k1/frontend/src/sensors/plugin';
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 contributions={[xgridsK1SensorUi]} EnrollmentView={K1EnrollmentWindow} createRerunHost={createIsolatedRerunHost} transport={transport}/>;}
+1
View File
@@ -0,0 +1 @@
import "../../../control-station/src/components/rerun/isolatedRerunEntry";
+3
View File
@@ -22,6 +22,9 @@
],
"@nodedc/ui-react": [
"node_modules/@nodedc/ui-react/dist/index.d.ts"
],
"@mission-core/sensor-sdk": [
"../../../packages/sensor-ui/src/pluginSdk.ts"
]
}
},
+9 -1
View File
@@ -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"] },
resolve: { alias: {"@mission-core/sensor-sdk": fileURLToPath(new URL("../../../packages/sensor-ui/src/pluginSdk.ts", import.meta.url))}, 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)),
} } },
});