Package onboard K1 separately with a private portable installer
This commit is contained in:
@@ -34,7 +34,6 @@ def provenance():
|
||||
"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}
|
||||
|
||||
|
||||
|
||||
@@ -4,16 +4,17 @@
|
||||
No install operation, sudo, container, package-manager mutation or network I/O.
|
||||
"""
|
||||
import argparse
|
||||
import gzip
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tarfile
|
||||
import sys
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
VERSION = "0.7.1"
|
||||
VERSION = "0.8.0"
|
||||
sys.path.insert(0, str(ROOT.parents[1] / "scripts/packaging"))
|
||||
from debian import package
|
||||
|
||||
BRAND_SHA256 = "8bfee8ca9f98e0db48d98aae3af4b32493b8593e18b064a0239d513d824182af"
|
||||
|
||||
|
||||
@@ -31,29 +32,6 @@ def desktop_icon(brand):
|
||||
+ brand + b'</svg>\n')
|
||||
|
||||
|
||||
def tarball(files):
|
||||
stream = io.BytesIO()
|
||||
with tarfile.open(fileobj=stream, mode="w", format=tarfile.GNU_FORMAT) as archive:
|
||||
directories = {str(parent) for name, _, _ in files for parent in Path(name).parents if str(parent) != "."}
|
||||
for name in sorted(directories):
|
||||
item = tarfile.TarInfo(name + "/")
|
||||
item.type, item.mode = tarfile.DIRTYPE, 0o755
|
||||
item.uname = item.gname = "root"
|
||||
archive.addfile(item)
|
||||
for name, data, mode in sorted(files):
|
||||
item = tarfile.TarInfo(name)
|
||||
item.size, item.mode, item.uid, item.gid = len(data), mode, 0, 0
|
||||
item.uname = item.gname = "root"
|
||||
archive.addfile(item, io.BytesIO(data))
|
||||
return gzip.compress(stream.getvalue(), mtime=0)
|
||||
|
||||
|
||||
def ar_member(name, data):
|
||||
header = f"{name + '/':<16}{0:<12}{0:<6}{0:<6}{'100644':<8}{len(data):<10}`\n".encode()
|
||||
assert len(header) == 60
|
||||
return header + data + (b"\n" if len(data) % 2 else b"")
|
||||
|
||||
|
||||
def build(binary, destination):
|
||||
payload = binary.read_bytes()
|
||||
if payload[:4] != b"\x7fELF" or payload[4:6] != b"\x02\x01" or payload[18:20] != b"\x3e\x00":
|
||||
@@ -65,7 +43,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, bluez, network-manager, iproute2, ffmpeg
|
||||
Depends: adduser, systemd, python3, python3-gi, gir1.2-gtk-3.0, gir1.2-webkit2-4.1, pkexec, polkitd, ca-certificates, hicolor-icon-theme, network-manager, iproute2
|
||||
Description: Mission Core onboard computer configuration
|
||||
Local graphical setup, host inventory, SSH access and persistent node identity.
|
||||
""".encode()
|
||||
@@ -107,35 +85,12 @@ 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))
|
||||
if (ROOT / "build/provenance.json").exists():
|
||||
files.append(("usr/share/doc/mission-core-node/provenance.json", (ROOT / "build/provenance.json").read_bytes(), 0o644))
|
||||
archive = b"!<arch>\n" + ar_member("debian-binary", b"2.0\n") + ar_member("control.tar.gz", tarball(controls)) + ar_member("data.tar.gz", tarball(files))
|
||||
archive = package(controls, files)
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
destination.write_bytes(archive)
|
||||
digest = hashlib.sha256(archive).hexdigest()
|
||||
|
||||
@@ -11,7 +11,8 @@ root = Path(__file__).resolve().parents[1]
|
||||
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())
|
||||
manifest_path = (root.parents[1] / "plugins/xgrids-k1/packaging/k1-bundle.json" if model == "k1" else root / "packaging/realsense-bundle.json")
|
||||
manifest = json.loads(manifest_path.read_text())
|
||||
output = root / f"build/{model}-wheels"
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
for item in manifest["wheels"]:
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
#!/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)
|
||||
@@ -8,10 +8,6 @@ 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
|
||||
@@ -19,8 +15,6 @@ 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
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# K1 private installer and onboard package boundary R2
|
||||
|
||||
The owner narrowed the acceptance scope to the current K1 and a transferable
|
||||
installer for supported onboard hosts. A second scanner and other firmware
|
||||
versions are explicitly deferred. The reviewed application material may travel
|
||||
with this private installer; it is not committed to source or made public.
|
||||
|
||||
## Evidence and decision
|
||||
|
||||
ADR 0012 and the retained LixelGO static/wire analysis distinguish the embedded
|
||||
application value from each scanner's live vendor ID and serial. A per-device
|
||||
packet dump is not part of the reviewed connection flow. We nevertheless claim
|
||||
only the currently accepted scanner/FW 3.0.2, not untested fleet-wide support.
|
||||
The current XGRIDS release notes also identify a communication-protocol change
|
||||
at 3.0.2 paired with LixelGO 1.2.0; compatibility remains version-scoped.
|
||||
|
||||
Autonomy here means installation on Ubuntu 24.04 amd64 without a Mac, Keychain,
|
||||
typed key or new capture. The private K1 package supplies the same reviewed
|
||||
material; its installer encrypts that material using the destination host's
|
||||
systemd credential facility. Normal Ubuntu repositories may be needed for OS
|
||||
dependencies. This is not an air-gapped distribution of Ubuntu itself.
|
||||
|
||||
## Implementation
|
||||
|
||||
Node 0.8.0 no longer owns the K1 Python runtime, Linux wheel bundle, service,
|
||||
polkit rule or credential importer. These moved to the optional
|
||||
`mission-core-xgrids-k1` package owned under `plugins/xgrids-k1/packaging`.
|
||||
Its dependencies and Debian migration metadata are explicit. Existing runtime,
|
||||
journal, identity and encrypted-credential paths are retained. Removal preserves
|
||||
durable state; package transitions keep the existing idle/safe-worker gate.
|
||||
|
||||
The code-only package is 0.1.0; the prepared private edition is 0.1.0+private.1.
|
||||
Private material enters the engineering builder through memory/stdin and is
|
||||
present only in a root-owned 0600 package member. The `.deb` itself is a private
|
||||
0600 artifact, not an encrypted distributable. Public provenance records only
|
||||
profile identity, material presence and hashes of public files. The installed
|
||||
worker uses the encrypted systemd credential, never the package member as a
|
||||
plaintext runtime fallback. Identical reinstall is idempotent; a different
|
||||
existing credential requires explicit rotation. No raw value reaches argv,
|
||||
environment, browser state, Ops or source files.
|
||||
|
||||
The onboard source payload now has an executable 85-module allowlist instead
|
||||
of copying every one of 459 Python modules. Changed imports fail packaging
|
||||
until reviewed. The Core/LAB factory moved into `composition.py` with identical
|
||||
AST; neither its behavior nor the device dialogue changed. Laboratory public
|
||||
exports became lazy so importing one historical migration predicate does not
|
||||
load laboratory runners and downstream compute jobs. Existing export names and
|
||||
resolved objects are retained.
|
||||
|
||||
## Validation before private release
|
||||
|
||||
- 138 installer, isolated-payload, application-authority, Node bridge, session,
|
||||
laboratory and composition checks passed. The synthetic installer checks
|
||||
encryption invocation, reinstall, refusal to rotate, invalid inputs,
|
||||
permissions, symlinks, private/public payload separation and no overwrite.
|
||||
- 775 additional plugin runtime, acquisition lifecycle, restart rehydration and
|
||||
laboratory API checks passed, with no errors, failures or skips.
|
||||
- Ruff and whitespace checks passed for changed runtime/new packaging modules.
|
||||
- Node TypeScript, production UI and Go binary/package build passed. No second
|
||||
backend or Docker workload was started.
|
||||
- The isolated runtime test uses the declared source payload and verifies all
|
||||
loaded K1/Core modules originate there. It reads idle state and closes the
|
||||
runtime; no BLE discovery, provisioning, MQTT, START/STOP or hardware test is
|
||||
performed. The local Mac lacks optional aiortc; full Node media import is a
|
||||
Linux installation acceptance, not a result claimed by this test.
|
||||
|
||||
Exact release hashes, installed package versions, credential readiness and
|
||||
hardware acceptance are recorded after the private build and board update.
|
||||
|
||||
## Remaining architecture scope
|
||||
|
||||
This is a separately installable onboard package, not completion of the entire
|
||||
plugin migration. Local Core still uses an in-process runtime and a statically
|
||||
composed frontend. The facade still needs internal decomposition, and some
|
||||
shared session/viewer code remains a declared plugin dependency. No other Rerun
|
||||
profile, camera producer or recovery state transition was changed here.
|
||||
Hardware acceptance uses the UI with cache cleared before every test, as
|
||||
requested. A second scanner, new firmware or different host platform is not
|
||||
accepted by packaging and software regression alone.
|
||||
|
||||
Vendor reference: https://www.xgrids.com/intl/support/download?page=K1
|
||||
@@ -0,0 +1,79 @@
|
||||
# Onboard K1 distribution
|
||||
|
||||
The accepted target is the owner's current activated K1, FW 3.0.2, on Ubuntu
|
||||
24.04 amd64. Another physical scanner or firmware is not included in this
|
||||
acceptance. The same prepared installer can be used on another supported
|
||||
onboard host without a Mac, Keychain export, new packet capture or typed key.
|
||||
|
||||
`mission-core-node` 0.8.0 contains the host, shared SDK, existing RealSense
|
||||
support and statically composed sensor UI. `mission-core-xgrids-k1` is an
|
||||
optional package containing the K1 service, its reviewed Python dependencies,
|
||||
platform adapters and application-material preparation. The optional package
|
||||
depends on the host; the host does not depend on the optional package.
|
||||
|
||||
The old 0.7.1 monolithic installation is migrated using Debian `Breaks` and
|
||||
`Replaces`. Existing filesystem paths, device identity, encrypted material,
|
||||
recordings and physical-command journals remain in place. Package scripts
|
||||
require a safe idle state and stop the worker before replacing its modules.
|
||||
Removing the optional package preserves recordings, journals and material.
|
||||
|
||||
## Private autonomous installer
|
||||
|
||||
The public code package contains no key. The private edition
|
||||
`0.1.0+private.1` carries the reviewed application material in one root-owned
|
||||
mode-0600 member. The resulting `.deb` is itself private (mode 0600); distribute
|
||||
it only as the owner's prepared installer, never through Git or a public
|
||||
package registry. File permissions on the installed member do not encrypt the
|
||||
distributable archive. This is a deliberate private distribution boundary,
|
||||
not a claim that a client application's embedded material is unextractable.
|
||||
|
||||
At installation, the fixed root-owned helper reads that member and uses
|
||||
`systemd-creds --with-key=host` to encrypt it for the destination computer.
|
||||
The worker gets only `LoadCredentialEncrypted=k1-application`. Reinstalling
|
||||
identical material is idempotent; a different existing value fails before
|
||||
replacement and requires explicit rotation. There is no plaintext runtime
|
||||
fallback, browser secret, environment variable, cloud service or per-scanner
|
||||
key derivation. The administrative protected-stdin importer remains supported.
|
||||
|
||||
Building the private edition reads the material only from protected stdin:
|
||||
|
||||
```text
|
||||
python plugins/xgrids-k1/packaging/build_deb.py
|
||||
--wheel-root <reviewed-wheel-cache>
|
||||
--output <private-output-directory>/mission-core-xgrids-k1_0.1.0+private.1_amd64.deb
|
||||
--private-authority-stdin
|
||||
```
|
||||
|
||||
This is an engineering build interface, not an operator prompt to type the key.
|
||||
An authorized build can read the existing fixed Keychain item once and supply
|
||||
those bytes in memory. No key value or key fingerprint enters public provenance.
|
||||
The build refuses to overwrite an existing artifact. Destination paths and
|
||||
credential values do not occur in source/runtime manifests.
|
||||
|
||||
Python wheels and application material are included. A fresh Ubuntu machine
|
||||
may still need its normal package repositories for declared OS dependencies;
|
||||
autonomous credential preparation does not claim an air-gapped OS installer.
|
||||
|
||||
## Runtime composition boundary
|
||||
|
||||
`runtime-files.json` declares the complete onboard Python import closure (85
|
||||
modules at this increment). The builder rejects changed imports until the list
|
||||
is reviewed. It includes shared session/viewer contracts that the existing K1
|
||||
runtime still consumes; it does not pretend those dependencies have disappeared.
|
||||
`composition.py` owns local Core/LAB router and archive composition. Its factory
|
||||
was moved from the facade without changing its AST. The onboard worker does not
|
||||
import it. Laboratory public exports are lazy to keep a leaf migration check
|
||||
from loading all laboratory runners on the board.
|
||||
|
||||
This finishes a separately installable onboard package boundary, not the entire
|
||||
device-plugin architecture. The local Core backend remains in-process, frontend
|
||||
composition remains static, and the internal K1 facade still requires further
|
||||
decomposition. No network protocol, recovery transition, START/STOP transcript,
|
||||
camera producer or LAB/recorded/live Rerun settings were changed here.
|
||||
|
||||
The source for application-level identity is the retained LixelGO static/wire
|
||||
analysis in `docs/05_K1_MQTT_STREAM_PROFILE.md` and ADR 0012. It distinguishes the
|
||||
application value from the scanner's live vendor ID and serial. Current vendor
|
||||
release notes also document a protocol change at FW 3.0.2 paired with LixelGO
|
||||
1.2.0; software-version compatibility must therefore remain explicit:
|
||||
https://www.xgrids.com/intl/support/download?page=K1
|
||||
@@ -0,0 +1,187 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build the optional onboard K1 integration, including a private autonomous edition.
|
||||
|
||||
The public build contains no application material. --private-authority-stdin
|
||||
creates a mode-0600 private installer; the key never appears in arguments,
|
||||
provenance, stdout or a source file. This installer itself is a private artifact.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
PACKAGING = Path(__file__).resolve().parent
|
||||
REPOSITORY = PACKAGING.parents[2]
|
||||
sys.path.insert(0, str(REPOSITORY / "scripts/packaging"))
|
||||
from credential_install import PROFILE_ID, validate # noqa: E402
|
||||
from debian import package # noqa: E402
|
||||
from runtime_payload import files as runtime_files # noqa: E402
|
||||
|
||||
VERSION = "0.1.0"
|
||||
RESOURCES = (
|
||||
"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",
|
||||
)
|
||||
|
||||
|
||||
def payload(wheel_root, *, authority=None):
|
||||
files = []
|
||||
for name in ("k1_prepare.py", "k1_bootstrap.py", "install-k1-credential"):
|
||||
files.append(
|
||||
(
|
||||
"usr/lib/mission-core-node/" + name,
|
||||
(PACKAGING / name).read_bytes(),
|
||||
0o755 if name == "install-k1-credential" else 0o644,
|
||||
)
|
||||
)
|
||||
files.extend(
|
||||
[
|
||||
(
|
||||
"usr/lib/mission-core-node/k1-install/credential_install.py",
|
||||
(PACKAGING / "credential_install.py").read_bytes(),
|
||||
0o644,
|
||||
),
|
||||
(
|
||||
"usr/lib/mission-core-node/prepare-k1-profile",
|
||||
(PACKAGING / "prepare-bundled-profile").read_bytes(),
|
||||
0o755,
|
||||
),
|
||||
(
|
||||
"usr/lib/systemd/system/mission-core-k1.service",
|
||||
(PACKAGING / "mission-core-k1.service").read_bytes(),
|
||||
0o644,
|
||||
),
|
||||
(
|
||||
"usr/share/polkit-1/rules.d/50-mission-core-k1.rules",
|
||||
(PACKAGING / "50-mission-core-k1.rules").read_bytes(),
|
||||
0o644,
|
||||
),
|
||||
(
|
||||
"usr/share/mission-core-node/k1/bundle.json",
|
||||
(PACKAGING / "k1-bundle.json").read_bytes(),
|
||||
0o644,
|
||||
),
|
||||
]
|
||||
)
|
||||
wheels = json.loads((PACKAGING / "k1-bundle.json").read_text())["wheels"]
|
||||
for item in wheels:
|
||||
path = wheel_root / item["name"]
|
||||
if path.name != item["name"] or path.is_symlink():
|
||||
raise ValueError("Unsafe K1 wheel path")
|
||||
data = path.read_bytes()
|
||||
if len(data) != item["bytes"] or hashlib.sha256(data).hexdigest() != item["sha256"]:
|
||||
raise ValueError("K1 wheel differs from the admitted bundle")
|
||||
files.append(("usr/share/mission-core-node/k1/" + item["name"], data, 0o644))
|
||||
sources = runtime_files()
|
||||
for path in sources:
|
||||
files.append(
|
||||
(
|
||||
"usr/lib/mission-core-node/k1/" + str(path.relative_to(REPOSITORY)),
|
||||
path.read_bytes(),
|
||||
0o644,
|
||||
)
|
||||
)
|
||||
for relative in RESOURCES:
|
||||
files.append(
|
||||
(
|
||||
"usr/lib/mission-core-node/k1/" + relative,
|
||||
(REPOSITORY / relative).read_bytes(),
|
||||
0o644,
|
||||
)
|
||||
)
|
||||
# Hash only code and public metadata. Never hash private material into a
|
||||
# public per-file manifest or represent it as scanner identity.
|
||||
provenance = {
|
||||
"package": "mission-core-xgrids-k1",
|
||||
"version": VERSION + ("+private.1" if authority is not None else ""),
|
||||
"base_commit": subprocess.check_output(
|
||||
["git", "rev-parse", "HEAD"], cwd=REPOSITORY, text=True
|
||||
).strip(),
|
||||
"application_profile": PROFILE_ID,
|
||||
"application_material_included": authority is not None,
|
||||
"acceptance_scope": "owner-controlled-k1-fw-3.0.2-ubuntu-24.04-amd64",
|
||||
"files": {name: hashlib.sha256(data).hexdigest() for name, data, _ in files},
|
||||
}
|
||||
files.append(
|
||||
(
|
||||
"usr/share/doc/mission-core-xgrids-k1/provenance.json",
|
||||
(json.dumps(provenance, indent=2) + "\n").encode(),
|
||||
0o644,
|
||||
)
|
||||
)
|
||||
if authority is not None:
|
||||
validate(authority)
|
||||
files.append(
|
||||
("usr/share/mission-core-node/k1/private-application-key", bytes(authority), 0o600)
|
||||
)
|
||||
return files
|
||||
|
||||
|
||||
def build(destination, wheel_root, *, authority=None):
|
||||
if authority is not None:
|
||||
validate(authority)
|
||||
version = VERSION + ("+private.1" if authority is not None else "")
|
||||
control = f"""Package: mission-core-xgrids-k1
|
||||
Version: {version}
|
||||
Architecture: amd64
|
||||
Maintainer: NODE.DC local build <noreply@example.invalid>
|
||||
Section: admin
|
||||
Priority: optional
|
||||
Depends: mission-core-node (>= 0.8.0), mission-core-node (<< 0.9.0),
|
||||
systemd, python3, adduser, bluez, network-manager, iproute2, ffmpeg
|
||||
Breaks: mission-core-node (<< 0.8.0)
|
||||
Replaces: mission-core-node (<< 0.8.0)
|
||||
Description: Optional XGRIDS K1 Bridge integration for Mission Core Node
|
||||
Reviewed FW 3.0.2 runtime with a separate process and durable device journals.
|
||||
""".encode()
|
||||
controls = [("control", control, 0o644)]
|
||||
controls += [
|
||||
(name, (PACKAGING / name).read_bytes(), 0o755)
|
||||
for name in ("preinst", "postinst", "prerm", "postrm")
|
||||
]
|
||||
archive = package(controls, payload(wheel_root, authority=authority))
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Never replace a previously published release with different bytes.
|
||||
fd, staged = tempfile.mkstemp(prefix=".k1-package-", dir=destination.parent)
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as stream:
|
||||
os.fchmod(stream.fileno(), 0o600 if authority is not None else 0o644)
|
||||
stream.write(archive)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.link(staged, destination)
|
||||
finally:
|
||||
os.unlink(staged)
|
||||
return {
|
||||
"package": destination.name,
|
||||
"version": version,
|
||||
"bytes": len(archive),
|
||||
"sha256": hashlib.sha256(archive).hexdigest(),
|
||||
"private": authority is not None,
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--wheel-root", type=Path, required=True)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument("--private-authority-stdin", action="store_true")
|
||||
args = parser.parse_args()
|
||||
secret = None
|
||||
try:
|
||||
if args.private_authority_stdin:
|
||||
secret = bytearray(sys.stdin.buffer.read(1025).strip())
|
||||
print(json.dumps(build(args.output, args.wheel_root, authority=secret)))
|
||||
except Exception:
|
||||
raise SystemExit("K1 package build failed; check inputs and unused output path") from None
|
||||
finally:
|
||||
if secret is not None:
|
||||
secret[:] = b"\0" * len(secret)
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Root-only preparation of the private application material shipped with K1.
|
||||
|
||||
An autonomous private installer supplies one fixed, root-readable file. The
|
||||
worker receives only a systemd credential encrypted on this board. Reinstalling
|
||||
the same material is harmless; changing it requires explicit rotation.
|
||||
"""
|
||||
|
||||
import hmac
|
||||
import os
|
||||
import stat
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
PROFILE_ID = "lixelgo.application.k1-fw-3.0.2.v1"
|
||||
STORE = Path("/etc/credstore.encrypted")
|
||||
BUNDLE = Path("/usr/share/mission-core-node/k1/private-application-key")
|
||||
|
||||
|
||||
class CredentialInstallError(ValueError):
|
||||
"""Secret-free failure; input and subprocess diagnostics never escape."""
|
||||
|
||||
|
||||
def validate(secret):
|
||||
if len(secret) != 36 or any(v < 33 or v > 126 for v in secret):
|
||||
raise CredentialInstallError("Invalid material for the reviewed K1 profile")
|
||||
|
||||
|
||||
def read_bundle(path=BUNDLE):
|
||||
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
||||
with os.fdopen(fd, "rb") as stream:
|
||||
info = os.fstat(stream.fileno())
|
||||
if not stat.S_ISREG(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o077:
|
||||
raise CredentialInstallError(
|
||||
"Private material must be a root-owned private regular file"
|
||||
)
|
||||
secret = bytearray(stream.read(1025))
|
||||
try:
|
||||
validate(secret)
|
||||
return secret
|
||||
except CredentialInstallError:
|
||||
secret[:] = b"\0" * len(secret)
|
||||
raise
|
||||
|
||||
|
||||
def install(secret, *, root=STORE, runner=subprocess.run):
|
||||
if os.geteuid() != 0:
|
||||
raise CredentialInstallError("Administrator authentication required")
|
||||
validate(secret)
|
||||
root.mkdir(mode=0o700, exist_ok=True)
|
||||
if root.is_symlink() or root.stat().st_uid != 0 or root.stat().st_mode & 0o022:
|
||||
raise CredentialInstallError("Unsafe credential store")
|
||||
path = root / "k1-application"
|
||||
if path.is_symlink():
|
||||
raise CredentialInstallError("Unsafe credential target")
|
||||
if path.exists():
|
||||
info = path.stat()
|
||||
if not stat.S_ISREG(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o077:
|
||||
raise CredentialInstallError("Unsafe installed credential")
|
||||
result = runner(
|
||||
["/usr/bin/systemd-creds", "decrypt", "--name=k1-application", str(path), "-"],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=30,
|
||||
check=False,
|
||||
)
|
||||
current = bytearray(result.stdout)
|
||||
try:
|
||||
if result.returncode or not hmac.compare_digest(current, secret):
|
||||
raise CredentialInstallError(
|
||||
"Existing material differs; explicit rotation required"
|
||||
)
|
||||
return "unchanged"
|
||||
finally:
|
||||
current[:] = b"\0" * len(current)
|
||||
with tempfile.TemporaryDirectory(prefix=".k1-", dir=root) as directory:
|
||||
staged = Path(directory) / "encrypted"
|
||||
result = runner(
|
||||
[
|
||||
"/usr/bin/systemd-creds",
|
||||
"encrypt",
|
||||
"--name=k1-application",
|
||||
"--with-key=host",
|
||||
"-",
|
||||
str(staged),
|
||||
],
|
||||
input=secret,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=30,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode:
|
||||
raise CredentialInstallError("Application material preparation failed")
|
||||
staged.chmod(0o600)
|
||||
with staged.open("rb") as stream:
|
||||
os.fsync(stream.fileno())
|
||||
os.link(staged, path)
|
||||
directory_fd = os.open(root, os.O_RDONLY | os.O_DIRECTORY)
|
||||
try:
|
||||
os.fsync(directory_fd)
|
||||
finally:
|
||||
os.close(directory_fd)
|
||||
return "installed"
|
||||
|
||||
|
||||
def main(*, bundled=False):
|
||||
import sys
|
||||
|
||||
secret = bytearray()
|
||||
try:
|
||||
if os.geteuid() != 0 or len(sys.argv) != 1:
|
||||
raise CredentialInstallError("Administrator-only preparation required")
|
||||
if bundled:
|
||||
if not BUNDLE.exists() and not BUNDLE.is_symlink():
|
||||
return
|
||||
secret = read_bundle()
|
||||
else:
|
||||
secret = bytearray(sys.stdin.buffer.read(1025).strip())
|
||||
install(secret)
|
||||
except (OSError, ValueError, subprocess.SubprocessError):
|
||||
raise SystemExit(
|
||||
"K1 application profile was not installed; "
|
||||
"check the private release or existing profile"
|
||||
) from None
|
||||
finally:
|
||||
secret[:] = b"\0" * len(secret)
|
||||
@@ -0,0 +1,12 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
mc_release_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
cd "$mc_release_dir"
|
||||
/usr/bin/sha256sum --check SHA256SUMS
|
||||
mc_node_package="$mc_release_dir/mission-core-node_0.8.0_amd64.deb"
|
||||
mc_k1_package="$mc_release_dir/mission-core-xgrids-k1_0.1.0+private.1_amd64.deb"
|
||||
if [ -t 0 ]; then
|
||||
exec /usr/bin/sudo /usr/bin/apt-get install -y "$mc_node_package" "$mc_k1_package"
|
||||
fi
|
||||
exec /usr/bin/gnome-terminal --wait --title="Mission Core · K1" -- \
|
||||
/usr/bin/sudo /usr/bin/apt-get install -y "$mc_node_package" "$mc_k1_package"
|
||||
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/python3 -I
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, "/usr/lib/mission-core-node/k1-install")
|
||||
from credential_install import main
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,16 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
case "$1" in
|
||||
configure)
|
||||
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
|
||||
/usr/lib/mission-core-node/prepare-k1-profile
|
||||
if [ -d /run/systemd/system ]; then
|
||||
systemctl daemon-reload
|
||||
systemctl enable mission-core-k1.service
|
||||
systemctl restart mission-core-k1.service
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,7 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
if [ -d /run/systemd/system ]; then
|
||||
systemctl daemon-reload
|
||||
fi
|
||||
# Removal preserves recordings, physical-command journals and encrypted
|
||||
# application material. Package removal is not device state reset or rotation.
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
case "$1" in
|
||||
install|upgrade)
|
||||
. /etc/os-release
|
||||
if [ "${ID:-}" != ubuntu ] || [ "${VERSION_ID:-}" != 24.04 ]; then
|
||||
echo "K1 integration requires Ubuntu 24.04 amd64." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -d /run/systemd/system ]; then
|
||||
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 "Завершите подключение или запись K1 перед обновлением интеграции." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
if [ -f /usr/lib/systemd/system/mission-core-k1.service ]; then
|
||||
systemctl stop mission-core-k1.service
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/python3 -I
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, "/usr/lib/mission-core-node/k1-install")
|
||||
from credential_install import main
|
||||
|
||||
main(bundled=True)
|
||||
@@ -0,0 +1,12 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
if [ -d /run/systemd/system ]; then
|
||||
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 "Завершите подключение или запись K1 перед удалением интеграции." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
systemctl stop mission-core-k1.service
|
||||
case "$1" in remove|deconfigure) systemctl disable mission-core-k1.service || true ;; esac
|
||||
fi
|
||||
@@ -0,0 +1,91 @@
|
||||
{
|
||||
"schema": "missioncore.plugin.python-payload/v1",
|
||||
"entrypoint": "k1link.device_plugins.xgrids_k1.node_bridge",
|
||||
"files": [
|
||||
"src/k1link/__init__.py",
|
||||
"src/k1link/artifact_gateway.py",
|
||||
"src/k1link/artifacts.py",
|
||||
"src/k1link/compute/__init__.py",
|
||||
"src/k1link/compute/live_perception.py",
|
||||
"src/k1link/data_plane/__init__.py",
|
||||
"src/k1link/data_plane/views.py",
|
||||
"src/k1link/device_plugins/__init__.py",
|
||||
"src/k1link/device_plugins/xgrids_k1/__init__.py",
|
||||
"src/k1link/device_plugins/xgrids_k1/active_acquisition_recovery_checkpoint.py",
|
||||
"src/k1link/device_plugins/xgrids_k1/application_control_process_lease.py",
|
||||
"src/k1link/device_plugins/xgrids_k1/ble/__init__.py",
|
||||
"src/k1link/device_plugins/xgrids_k1/ble/ap_activation.py",
|
||||
"src/k1link/device_plugins/xgrids_k1/ble/runtime_arbiter.py",
|
||||
"src/k1link/device_plugins/xgrids_k1/ble/scanner.py",
|
||||
"src/k1link/device_plugins/xgrids_k1/ble/wifi_provisioning.py",
|
||||
"src/k1link/device_plugins/xgrids_k1/calibration_schema.py",
|
||||
"src/k1link/device_plugins/xgrids_k1/calibration_snapshot.py",
|
||||
"src/k1link/device_plugins/xgrids_k1/camera.py",
|
||||
"src/k1link/device_plugins/xgrids_k1/connection_attempt.py",
|
||||
"src/k1link/device_plugins/xgrids_k1/connection_supervisor.py",
|
||||
"src/k1link/device_plugins/xgrids_k1/device_identity_pin_store.py",
|
||||
"src/k1link/device_plugins/xgrids_k1/facade.py",
|
||||
"src/k1link/device_plugins/xgrids_k1/firmware_credential.py",
|
||||
"src/k1link/device_plugins/xgrids_k1/host_diagnostics.py",
|
||||
"src/k1link/device_plugins/xgrids_k1/linux_host.py",
|
||||
"src/k1link/device_plugins/xgrids_k1/live_perception_shadow.py",
|
||||
"src/k1link/device_plugins/xgrids_k1/mqtt/__init__.py",
|
||||
"src/k1link/device_plugins/xgrids_k1/mqtt/capture.py",
|
||||
"src/k1link/device_plugins/xgrids_k1/network_mutation_ledger.py",
|
||||
"src/k1link/device_plugins/xgrids_k1/network_provisioning_idempotency_journal.py",
|
||||
"src/k1link/device_plugins/xgrids_k1/node_bridge.py",
|
||||
"src/k1link/device_plugins/xgrids_k1/node_sensor.py",
|
||||
"src/k1link/device_plugins/xgrids_k1/physical_command_coordinator.py",
|
||||
"src/k1link/device_plugins/xgrids_k1/physical_command_ledger.py",
|
||||
"src/k1link/device_plugins/xgrids_k1/protocol/__init__.py",
|
||||
"src/k1link/device_plugins/xgrids_k1/protocol/application_acceptance.py",
|
||||
"src/k1link/device_plugins/xgrids_k1/protocol/application_authority.py",
|
||||
"src/k1link/device_plugins/xgrids_k1/protocol/application_bootstrap.py",
|
||||
"src/k1link/device_plugins/xgrids_k1/protocol/application_execution.py",
|
||||
"src/k1link/device_plugins/xgrids_k1/protocol/application_mqtt.py",
|
||||
"src/k1link/device_plugins/xgrids_k1/protocol/application_publish.py",
|
||||
"src/k1link/device_plugins/xgrids_k1/protocol/application_session.py",
|
||||
"src/k1link/device_plugins/xgrids_k1/protocol/calibration_file.py",
|
||||
"src/k1link/device_plugins/xgrids_k1/protocol/calibration_mqtt.py",
|
||||
"src/k1link/device_plugins/xgrids_k1/protocol/modeling.py",
|
||||
"src/k1link/device_plugins/xgrids_k1/protocol/modeling_control.py",
|
||||
"src/k1link/device_plugins/xgrids_k1/protocol/modeling_safety.py",
|
||||
"src/k1link/device_plugins/xgrids_k1/protocol/normalizer.py",
|
||||
"src/k1link/device_plugins/xgrids_k1/protocol/protobuf_wire.py",
|
||||
"src/k1link/device_plugins/xgrids_k1/protocol/streams.py",
|
||||
"src/k1link/device_plugins/xgrids_k1/quick_connect_profile.py",
|
||||
"src/k1link/device_plugins/xgrids_k1/semantic_topology_store.py",
|
||||
"src/k1link/device_plugins/xgrids_k1/viewer/__init__.py",
|
||||
"src/k1link/device_plugins/xgrids_k1/viewer/messages.py",
|
||||
"src/k1link/device_plugins/xgrids_k1/viewer/replay.py",
|
||||
"src/k1link/device_plugins/xgrids_k1/viewer/runtime.py",
|
||||
"src/k1link/device_plugins/xgrids_k1/wifi_failure.py",
|
||||
"src/k1link/host_network/__init__.py",
|
||||
"src/k1link/host_network/wifi.py",
|
||||
"src/k1link/laboratory/__init__.py",
|
||||
"src/k1link/laboratory/canonical_recorded_migration.py",
|
||||
"src/k1link/media_fragments.py",
|
||||
"src/k1link/sessions/__init__.py",
|
||||
"src/k1link/sessions/active.py",
|
||||
"src/k1link/sessions/camera_frame.py",
|
||||
"src/k1link/sessions/equipment.py",
|
||||
"src/k1link/sessions/lab_cache.py",
|
||||
"src/k1link/sessions/media.py",
|
||||
"src/k1link/sessions/models.py",
|
||||
"src/k1link/sessions/plugin_contract.py",
|
||||
"src/k1link/sessions/preparation.py",
|
||||
"src/k1link/sessions/recording.py",
|
||||
"src/k1link/sessions/store.py",
|
||||
"src/k1link/viewer/__init__.py",
|
||||
"src/k1link/viewer/metrics.py",
|
||||
"src/k1link/viewer/node_media.py",
|
||||
"src/k1link/viewer/node_rerun.py",
|
||||
"src/k1link/viewer/recorded.py",
|
||||
"src/k1link/viewer/recorded_blueprint_lifecycle.py",
|
||||
"src/k1link/viewer/rerun_bridge.py",
|
||||
"src/k1link/web/__init__.py",
|
||||
"src/k1link/web/camera_archive.py",
|
||||
"src/k1link/web/device_lifecycle.py",
|
||||
"src/k1link/web/plugin_runtime.py"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Reviewable import closure for the onboard plugin; no entire Core tree."""
|
||||
|
||||
import ast
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
MANIFEST = Path(__file__).with_name("runtime-files.json")
|
||||
ENTRYPOINT = "k1link.device_plugins.xgrids_k1.node_bridge"
|
||||
|
||||
|
||||
def runtime_nodes(node):
|
||||
# A lazy package's typing-only reexports must not ship every LAB algorithm.
|
||||
if isinstance(node, ast.If) and (
|
||||
isinstance(node.test, ast.Name)
|
||||
and node.test.id == "TYPE_CHECKING"
|
||||
or isinstance(node.test, ast.Attribute)
|
||||
and node.test.attr == "TYPE_CHECKING"
|
||||
):
|
||||
for child in node.orelse:
|
||||
yield from runtime_nodes(child)
|
||||
return
|
||||
yield node
|
||||
for child in ast.iter_child_nodes(node):
|
||||
yield from runtime_nodes(child)
|
||||
|
||||
|
||||
def import_closure(repository=ROOT):
|
||||
source = repository / "src"
|
||||
modules = {}
|
||||
for path in (source / "k1link").rglob("*.py"):
|
||||
name = ".".join(path.relative_to(source).with_suffix("").parts).removesuffix(".__init__")
|
||||
modules[name] = path
|
||||
seen, todo = set(), [ENTRYPOINT]
|
||||
while todo:
|
||||
name = todo.pop()
|
||||
if name in seen:
|
||||
continue
|
||||
seen.add(name)
|
||||
path = modules[name]
|
||||
package = name if path.name == "__init__.py" else name.rpartition(".")[0]
|
||||
imports = {".".join(name.split(".")[:i]) for i in range(1, len(name.split(".")))}
|
||||
for node in runtime_nodes(ast.parse(path.read_text())):
|
||||
if isinstance(node, ast.Import):
|
||||
imports.update(a.name for a in node.names)
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
base = node.module or ""
|
||||
if node.level:
|
||||
prefix = ".".join(
|
||||
package.split(".")[: len(package.split(".")) - node.level + 1]
|
||||
)
|
||||
base = prefix + ("." + base if base else "")
|
||||
imports.add(base)
|
||||
imports.update(base + "." + a.name for a in node.names)
|
||||
todo.extend(sorted((imports & modules.keys()) - seen))
|
||||
return sorted(str(modules[name].relative_to(repository)) for name in seen)
|
||||
|
||||
|
||||
def files(repository=ROOT):
|
||||
declared = json.loads(MANIFEST.read_text())
|
||||
actual = import_closure(repository)
|
||||
if (
|
||||
declared["schema"] != "missioncore.plugin.python-payload/v1"
|
||||
or declared["entrypoint"] != ENTRYPOINT
|
||||
or actual != declared["files"]
|
||||
):
|
||||
raise ValueError("K1 runtime imports changed; review its payload manifest before packaging")
|
||||
result = []
|
||||
for name in actual:
|
||||
path = repository / name
|
||||
if path.is_symlink():
|
||||
raise ValueError("Symlink in plugin source payload")
|
||||
result.append(path)
|
||||
return result
|
||||
|
||||
|
||||
def provenance(repository=ROOT):
|
||||
return {
|
||||
str(p.relative_to(repository)): hashlib.sha256(p.read_bytes()).hexdigest()
|
||||
for p in files(repository)
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"schema": "missioncore.plugin.python-payload/v1",
|
||||
"entrypoint": ENTRYPOINT,
|
||||
"files": import_closure(),
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
@@ -9,7 +9,7 @@
|
||||
"spec": {
|
||||
"hostApiRange": "v1alpha2",
|
||||
"runtime": {
|
||||
"backendEntrypoint": "k1link.device_plugins.xgrids_k1.facade:build_xgrids_k1_plugin",
|
||||
"backendEntrypoint": "k1link.device_plugins.xgrids_k1.composition:build_xgrids_k1_plugin",
|
||||
"isolation": "transitional-in-process"
|
||||
},
|
||||
"compatibilityProfiles": [
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Deterministic archive primitives shared by host and optional plugin builders."""
|
||||
|
||||
import gzip
|
||||
import io
|
||||
import tarfile
|
||||
from pathlib import PurePosixPath
|
||||
|
||||
|
||||
def tarball(files):
|
||||
names = [name for name, _, _ in files]
|
||||
if len(set(names)) != len(names):
|
||||
raise ValueError("Duplicate package member")
|
||||
if any(PurePosixPath(n).is_absolute() or ".." in PurePosixPath(n).parts for n in names):
|
||||
raise ValueError("Unsafe package member")
|
||||
stream = io.BytesIO()
|
||||
with tarfile.open(fileobj=stream, mode="w", format=tarfile.GNU_FORMAT) as archive:
|
||||
directories = {
|
||||
str(parent)
|
||||
for name in names
|
||||
for parent in PurePosixPath(name).parents
|
||||
if str(parent) != "."
|
||||
}
|
||||
for name in sorted(directories):
|
||||
item = tarfile.TarInfo(name + "/")
|
||||
item.type, item.mode = tarfile.DIRTYPE, 0o755
|
||||
item.uname = item.gname = "root"
|
||||
archive.addfile(item)
|
||||
for name, data, mode in sorted(files):
|
||||
item = tarfile.TarInfo(name)
|
||||
item.size, item.mode, item.uid, item.gid = len(data), mode, 0, 0
|
||||
item.uname = item.gname = "root"
|
||||
archive.addfile(item, io.BytesIO(data))
|
||||
return gzip.compress(stream.getvalue(), mtime=0)
|
||||
|
||||
|
||||
def ar_member(name, data):
|
||||
header = f"{name + '/':<16}{0:<12}{0:<6}{0:<6}{'100644':<8}{len(data):<10}`\n".encode()
|
||||
if len(header) != 60:
|
||||
raise ValueError("Invalid archive header")
|
||||
return header + data + (b"\n" if len(data) % 2 else b"")
|
||||
|
||||
|
||||
def package(controls, files):
|
||||
return (
|
||||
b"!<arch>\n"
|
||||
+ ar_member("debian-binary", b"2.0\n")
|
||||
+ ar_member("control.tar.gz", tarball(controls))
|
||||
+ ar_member("data.tar.gz", tarball(files))
|
||||
)
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Local LAB composition; the onboard runtime does not import this module."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from missioncore_plugin_sdk.v0alpha2 import RuntimePluginDescriptor
|
||||
|
||||
from k1link.web.plugin_runtime import DevicePluginRuntimeContribution, InProcessDevicePluginRuntime
|
||||
|
||||
from .camera import build_xgrids_k1_camera_router
|
||||
from .facade import (
|
||||
XGRIDS_K1_PLUGIN_ID,
|
||||
XGRIDS_K1_PLUGIN_VERSION,
|
||||
XgridsK1CompatibilityService,
|
||||
XgridsK1PluginFacade,
|
||||
_validate_installed_compatibility_profile,
|
||||
)
|
||||
from .live_perception_shadow import (
|
||||
build_live_perception_result_receiver,
|
||||
build_live_perception_shadow_router,
|
||||
)
|
||||
|
||||
|
||||
def build_xgrids_k1_plugin(repository_root: Path) -> DevicePluginRuntimeContribution:
|
||||
"""Manifest entrypoint for the reviewed XGRIDS compatibility adapter."""
|
||||
|
||||
from k1link.device_plugins.xgrids_k1 import build_xgrids_k1_observation
|
||||
from k1link.device_plugins.xgrids_k1.legacy_api import build_xgrids_k1_legacy_router
|
||||
|
||||
_validate_installed_compatibility_profile(repository_root)
|
||||
service = XgridsK1CompatibilityService(repository_root)
|
||||
adapter = XgridsK1PluginFacade(service)
|
||||
runtime = InProcessDevicePluginRuntime(
|
||||
adapter,
|
||||
RuntimePluginDescriptor(
|
||||
plugin_id=XGRIDS_K1_PLUGIN_ID,
|
||||
plugin_version=XGRIDS_K1_PLUGIN_VERSION,
|
||||
supported_host_api_versions=("missioncore.nodedc/v1alpha2",),
|
||||
action_ids=tuple(sorted(adapter.action_ids)),
|
||||
),
|
||||
close=service.close,
|
||||
)
|
||||
return DevicePluginRuntimeContribution(
|
||||
runtime=runtime,
|
||||
legacy_routers=(
|
||||
build_xgrids_k1_legacy_router(runtime),
|
||||
build_xgrids_k1_camera_router(
|
||||
service.camera_preview,
|
||||
XGRIDS_K1_PLUGIN_ID,
|
||||
),
|
||||
build_live_perception_shadow_router(
|
||||
service.live_perception_ingress,
|
||||
XGRIDS_K1_PLUGIN_ID,
|
||||
bearer_token=service._live_perception_token,
|
||||
result_receiver=build_live_perception_result_receiver(
|
||||
service.live_perception_ingress,
|
||||
service.runtime.publish_perception_frame,
|
||||
),
|
||||
),
|
||||
),
|
||||
observation=build_xgrids_k1_observation(repository_root),
|
||||
)
|
||||
@@ -31,7 +31,6 @@ from uuid import uuid4
|
||||
from bleak.exc import BleakDeviceNotFoundError, BleakError
|
||||
from missioncore_plugin_sdk.v0alpha2 import (
|
||||
RuntimeActionInvocation,
|
||||
RuntimePluginDescriptor,
|
||||
)
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
@@ -120,7 +119,6 @@ from k1link.device_plugins.xgrids_k1.camera import (
|
||||
CameraSourceId,
|
||||
CommittedCameraSegment,
|
||||
XgridsK1CameraGateway,
|
||||
build_xgrids_k1_camera_router,
|
||||
classify_camera_recording_health,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.connection_attempt import (
|
||||
@@ -155,8 +153,6 @@ from k1link.device_plugins.xgrids_k1.host_diagnostics import (
|
||||
host_diagnostic_for_reason,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.live_perception_shadow import (
|
||||
build_live_perception_result_receiver,
|
||||
build_live_perception_shadow_router,
|
||||
ensure_live_shadow_token,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.mqtt import validate_private_ipv4
|
||||
@@ -263,8 +259,6 @@ from k1link.web.device_lifecycle import (
|
||||
new_device_session_id,
|
||||
)
|
||||
from k1link.web.plugin_runtime import (
|
||||
DevicePluginRuntimeContribution,
|
||||
InProcessDevicePluginRuntime,
|
||||
PluginActionNotFoundError,
|
||||
PluginExecutionError,
|
||||
)
|
||||
@@ -35506,44 +35500,3 @@ def _validate_installed_compatibility_profile(repository_root: Path) -> None:
|
||||
XGRIDS_K1_COMPATIBILITY_PROFILE_ID
|
||||
):
|
||||
raise RuntimeError("XGRIDS compatibility profile identity does not match the runtime")
|
||||
|
||||
|
||||
def build_xgrids_k1_plugin(repository_root: Path) -> DevicePluginRuntimeContribution:
|
||||
"""Manifest entrypoint for the reviewed XGRIDS compatibility adapter."""
|
||||
|
||||
from k1link.device_plugins.xgrids_k1 import build_xgrids_k1_observation
|
||||
from k1link.device_plugins.xgrids_k1.legacy_api import build_xgrids_k1_legacy_router
|
||||
|
||||
_validate_installed_compatibility_profile(repository_root)
|
||||
service = XgridsK1CompatibilityService(repository_root)
|
||||
adapter = XgridsK1PluginFacade(service)
|
||||
runtime = InProcessDevicePluginRuntime(
|
||||
adapter,
|
||||
RuntimePluginDescriptor(
|
||||
plugin_id=XGRIDS_K1_PLUGIN_ID,
|
||||
plugin_version=XGRIDS_K1_PLUGIN_VERSION,
|
||||
supported_host_api_versions=("missioncore.nodedc/v1alpha2",),
|
||||
action_ids=tuple(sorted(adapter.action_ids)),
|
||||
),
|
||||
close=service.close,
|
||||
)
|
||||
return DevicePluginRuntimeContribution(
|
||||
runtime=runtime,
|
||||
legacy_routers=(
|
||||
build_xgrids_k1_legacy_router(runtime),
|
||||
build_xgrids_k1_camera_router(
|
||||
service.camera_preview,
|
||||
XGRIDS_K1_PLUGIN_ID,
|
||||
),
|
||||
build_live_perception_shadow_router(
|
||||
service.live_perception_ingress,
|
||||
XGRIDS_K1_PLUGIN_ID,
|
||||
bearer_token=service._live_perception_token,
|
||||
result_receiver=build_live_perception_result_receiver(
|
||||
service.live_perception_ingress,
|
||||
service.runtime.publish_perception_frame,
|
||||
),
|
||||
),
|
||||
),
|
||||
observation=build_xgrids_k1_observation(repository_root),
|
||||
)
|
||||
|
||||
@@ -1,38 +1,42 @@
|
||||
"""Configuration contracts for Mission Core laboratory evidence."""
|
||||
"""Lazy public exports for laboratory evidence; leaf imports stay lightweight."""
|
||||
|
||||
from k1link.laboratory.evidence_registry import (
|
||||
LABORATORY_EVIDENCE_DEFINITION_SCHEMA,
|
||||
LABORATORY_EVIDENCE_LIFECYCLE_DEFINITION_SCHEMA,
|
||||
LaboratoryEvidenceDefinition,
|
||||
LaboratoryEvidenceRegistry,
|
||||
LaboratoryEvidenceVariant,
|
||||
LaboratoryRegistryError,
|
||||
)
|
||||
from k1link.laboratory.evidence_report import (
|
||||
LABORATORY_EVIDENCE_REPORT_SCHEMA,
|
||||
LaboratoryEvidenceReportError,
|
||||
LaboratoryEvidenceReportNotFound,
|
||||
LaboratoryEvidenceReportService,
|
||||
verify_laboratory_evidence_result,
|
||||
)
|
||||
from k1link.laboratory.execution import (
|
||||
LABORATORY_EXECUTION_REGISTRY_SCHEMA,
|
||||
LABORATORY_RUN_RECEIPT_SCHEMA,
|
||||
LaboratoryAdapterResult,
|
||||
LaboratoryExecutionDefinition,
|
||||
LaboratoryExecutionError,
|
||||
LaboratoryExecutionRegistry,
|
||||
LaboratoryRunner,
|
||||
LaboratoryRunRequest,
|
||||
LaboratoryRunResult,
|
||||
)
|
||||
from k1link.laboratory.value_review_registry import (
|
||||
LABORATORY_VALUE_REVIEW_INDEX_SCHEMA,
|
||||
LABORATORY_VALUE_REVIEW_REGISTRY_SCHEMA,
|
||||
LaboratoryValueReviewEntry,
|
||||
LaboratoryValueReviewRegistry,
|
||||
LaboratoryValueReviewRegistryError,
|
||||
)
|
||||
from importlib import import_module
|
||||
from typing import TYPE_CHECKING, Any, Final
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from k1link.laboratory.evidence_registry import (
|
||||
LABORATORY_EVIDENCE_DEFINITION_SCHEMA,
|
||||
LABORATORY_EVIDENCE_LIFECYCLE_DEFINITION_SCHEMA,
|
||||
LaboratoryEvidenceDefinition,
|
||||
LaboratoryEvidenceRegistry,
|
||||
LaboratoryEvidenceVariant,
|
||||
LaboratoryRegistryError,
|
||||
)
|
||||
from k1link.laboratory.evidence_report import (
|
||||
LABORATORY_EVIDENCE_REPORT_SCHEMA,
|
||||
LaboratoryEvidenceReportError,
|
||||
LaboratoryEvidenceReportNotFound,
|
||||
LaboratoryEvidenceReportService,
|
||||
verify_laboratory_evidence_result,
|
||||
)
|
||||
from k1link.laboratory.execution import (
|
||||
LABORATORY_EXECUTION_REGISTRY_SCHEMA,
|
||||
LABORATORY_RUN_RECEIPT_SCHEMA,
|
||||
LaboratoryAdapterResult,
|
||||
LaboratoryExecutionDefinition,
|
||||
LaboratoryExecutionError,
|
||||
LaboratoryExecutionRegistry,
|
||||
LaboratoryRunner,
|
||||
LaboratoryRunRequest,
|
||||
LaboratoryRunResult,
|
||||
)
|
||||
from k1link.laboratory.value_review_registry import (
|
||||
LABORATORY_VALUE_REVIEW_INDEX_SCHEMA,
|
||||
LABORATORY_VALUE_REVIEW_REGISTRY_SCHEMA,
|
||||
LaboratoryValueReviewEntry,
|
||||
LaboratoryValueReviewRegistry,
|
||||
LaboratoryValueReviewRegistryError,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"LABORATORY_EVIDENCE_DEFINITION_SCHEMA",
|
||||
@@ -61,3 +65,44 @@ __all__ = [
|
||||
"LaboratoryValueReviewRegistry",
|
||||
"LaboratoryValueReviewRegistryError",
|
||||
]
|
||||
|
||||
_EXPORTS: Final = {
|
||||
"LABORATORY_EVIDENCE_DEFINITION_SCHEMA": "k1link.laboratory.evidence_registry",
|
||||
"LABORATORY_EVIDENCE_LIFECYCLE_DEFINITION_SCHEMA": "k1link.laboratory.evidence_registry",
|
||||
"LaboratoryEvidenceDefinition": "k1link.laboratory.evidence_registry",
|
||||
"LaboratoryEvidenceRegistry": "k1link.laboratory.evidence_registry",
|
||||
"LaboratoryEvidenceVariant": "k1link.laboratory.evidence_registry",
|
||||
"LaboratoryRegistryError": "k1link.laboratory.evidence_registry",
|
||||
"LABORATORY_EVIDENCE_REPORT_SCHEMA": "k1link.laboratory.evidence_report",
|
||||
"LaboratoryEvidenceReportError": "k1link.laboratory.evidence_report",
|
||||
"LaboratoryEvidenceReportNotFound": "k1link.laboratory.evidence_report",
|
||||
"LaboratoryEvidenceReportService": "k1link.laboratory.evidence_report",
|
||||
"verify_laboratory_evidence_result": "k1link.laboratory.evidence_report",
|
||||
"LABORATORY_EXECUTION_REGISTRY_SCHEMA": "k1link.laboratory.execution",
|
||||
"LABORATORY_RUN_RECEIPT_SCHEMA": "k1link.laboratory.execution",
|
||||
"LaboratoryAdapterResult": "k1link.laboratory.execution",
|
||||
"LaboratoryExecutionDefinition": "k1link.laboratory.execution",
|
||||
"LaboratoryExecutionError": "k1link.laboratory.execution",
|
||||
"LaboratoryExecutionRegistry": "k1link.laboratory.execution",
|
||||
"LaboratoryRunner": "k1link.laboratory.execution",
|
||||
"LaboratoryRunRequest": "k1link.laboratory.execution",
|
||||
"LaboratoryRunResult": "k1link.laboratory.execution",
|
||||
"LABORATORY_VALUE_REVIEW_INDEX_SCHEMA": "k1link.laboratory.value_review_registry",
|
||||
"LABORATORY_VALUE_REVIEW_REGISTRY_SCHEMA": "k1link.laboratory.value_review_registry",
|
||||
"LaboratoryValueReviewEntry": "k1link.laboratory.value_review_registry",
|
||||
"LaboratoryValueReviewRegistry": "k1link.laboratory.value_review_registry",
|
||||
"LaboratoryValueReviewRegistryError": "k1link.laboratory.value_review_registry",
|
||||
}
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
module_name = _EXPORTS.get(name)
|
||||
if module_name is None:
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
value = getattr(import_module(module_name), name)
|
||||
globals()[name] = value
|
||||
return value
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
return sorted({*globals(), *__all__})
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
"""Private installer and import-boundary checks with synthetic material only."""
|
||||
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
PACKAGING = ROOT / "plugins/xgrids-k1/packaging"
|
||||
SYNTHETIC_KEY = b"11111111-2222-3333-4444-555555555555"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def modules(monkeypatch):
|
||||
monkeypatch.syspath_prepend(str(PACKAGING))
|
||||
from importlib import import_module
|
||||
|
||||
credential = import_module("credential_install")
|
||||
runtime = import_module("runtime_payload")
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"k1_package_builder_test", PACKAGING / "build_deb.py"
|
||||
)
|
||||
builder = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(builder)
|
||||
return credential, runtime, builder
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def root_metadata(monkeypatch, tmp_path, modules):
|
||||
credential, _, _ = modules
|
||||
original_stat = Path.stat
|
||||
original_fstat = os.fstat
|
||||
|
||||
def owned(info):
|
||||
values = list(info)
|
||||
values[4] = 0
|
||||
return os.stat_result(values)
|
||||
|
||||
def file_stat(path, *args, **kwargs):
|
||||
info = original_stat(path, *args, **kwargs)
|
||||
return owned(info) if path.is_relative_to(tmp_path) else info
|
||||
|
||||
monkeypatch.setattr(Path, "stat", file_stat)
|
||||
monkeypatch.setattr(credential.os, "fstat", lambda fd: owned(original_fstat(fd)))
|
||||
monkeypatch.setattr(credential.os, "geteuid", lambda: 0)
|
||||
return credential
|
||||
|
||||
|
||||
def test_private_material_is_encrypted_on_each_host_and_reinstall_is_idempotent(
|
||||
root_metadata,
|
||||
tmp_path,
|
||||
):
|
||||
credential = root_metadata
|
||||
calls = []
|
||||
|
||||
def runner(args, **kwargs):
|
||||
calls.append(args)
|
||||
assert SYNTHETIC_KEY.decode() not in str(args)
|
||||
if args[1] == "encrypt":
|
||||
assert kwargs["input"] == SYNTHETIC_KEY
|
||||
Path(args[-1]).write_bytes(b"synthetic-host-encrypted-material")
|
||||
return subprocess.CompletedProcess(args, 0)
|
||||
return subprocess.CompletedProcess(args, 0, stdout=SYNTHETIC_KEY)
|
||||
|
||||
store = tmp_path / "credentials"
|
||||
assert credential.install(bytearray(SYNTHETIC_KEY), root=store, runner=runner) == "installed"
|
||||
before = (store / "k1-application").stat().st_ino
|
||||
assert credential.install(bytearray(SYNTHETIC_KEY), root=store, runner=runner) == "unchanged"
|
||||
assert (store / "k1-application").stat().st_ino == before
|
||||
assert (store / "k1-application").stat().st_mode & 0o777 == 0o600
|
||||
assert [args[1] for args in calls] == ["encrypt", "decrypt"]
|
||||
|
||||
|
||||
def test_different_material_never_rotates_an_existing_installation(root_metadata, tmp_path):
|
||||
store = tmp_path / "credentials"
|
||||
store.mkdir(mode=0o700)
|
||||
target = store / "k1-application"
|
||||
target.write_bytes(b"old-encrypted-credential")
|
||||
target.chmod(0o600)
|
||||
|
||||
def runner(args, **kwargs):
|
||||
assert args[1] == "decrypt"
|
||||
return subprocess.CompletedProcess(args, 0, stdout=b"a-different-private-application-value")
|
||||
|
||||
with pytest.raises(ValueError, match="explicit rotation") as error:
|
||||
root_metadata.install(bytearray(SYNTHETIC_KEY), root=store, runner=runner)
|
||||
assert SYNTHETIC_KEY.decode() not in str(error.value)
|
||||
assert target.read_bytes() == b"old-encrypted-credential"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", [b"", b"short", b"x" * 1025, b"\x00" * 36, b" " * 36])
|
||||
def test_bad_material_fails_before_system_tools(modules, tmp_path, bad):
|
||||
credential, _, _ = modules
|
||||
|
||||
def forbidden(*args, **kwargs):
|
||||
pytest.fail("Invalid material reached an operating-system command")
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
credential.install(bytearray(bad), root=tmp_path / "absent", runner=forbidden)
|
||||
assert not (tmp_path / "absent").exists()
|
||||
|
||||
|
||||
def test_bundle_rejects_symlink_and_public_permissions(root_metadata, tmp_path):
|
||||
target = tmp_path / "profile"
|
||||
target.write_bytes(SYNTHETIC_KEY)
|
||||
target.chmod(0o644)
|
||||
with pytest.raises(ValueError):
|
||||
root_metadata.read_bundle(target)
|
||||
target.chmod(0o600)
|
||||
link = tmp_path / "link"
|
||||
link.symlink_to(target)
|
||||
with pytest.raises(OSError):
|
||||
root_metadata.read_bundle(link)
|
||||
assert root_metadata.read_bundle(target) == SYNTHETIC_KEY
|
||||
|
||||
|
||||
def test_private_release_contains_material_only_in_root_private_member(
|
||||
modules, monkeypatch, tmp_path
|
||||
):
|
||||
_, _, builder = modules
|
||||
packaging = tmp_path / "packaging"
|
||||
shutil.copytree(PACKAGING, packaging, ignore=shutil.ignore_patterns("__pycache__"))
|
||||
(packaging / "k1-bundle.json").write_text('{"wheels": []}')
|
||||
monkeypatch.setattr(builder, "PACKAGING", packaging)
|
||||
public = builder.payload(tmp_path)
|
||||
private = builder.payload(tmp_path, authority=bytearray(SYNTHETIC_KEY))
|
||||
assert all(SYNTHETIC_KEY not in data for _, data, _ in public)
|
||||
secret_members = [(name, mode) for name, data, mode in private if SYNTHETIC_KEY in data]
|
||||
assert secret_members == [("usr/share/mission-core-node/k1/private-application-key", 0o600)]
|
||||
provenance = next(data for name, data, _ in private if name.endswith("provenance.json"))
|
||||
metadata = json.loads(provenance)
|
||||
assert metadata["application_material_included"] is True
|
||||
assert not any("private-application-key" in key for key in metadata["files"])
|
||||
assert metadata["acceptance_scope"] == "owner-controlled-k1-fw-3.0.2-ubuntu-24.04-amd64"
|
||||
|
||||
destination = tmp_path / "private.deb"
|
||||
builder.build(destination, tmp_path, authority=bytearray(SYNTHETIC_KEY))
|
||||
assert destination.stat().st_mode & 0o777 == 0o600
|
||||
original = destination.read_bytes()
|
||||
with pytest.raises(FileExistsError):
|
||||
builder.build(destination, tmp_path, authority=bytearray(SYNTHETIC_KEY))
|
||||
assert destination.read_bytes() == original
|
||||
position = 8
|
||||
members = {}
|
||||
while position < len(original):
|
||||
header = original[position : position + 60]
|
||||
length = int(header[48:58])
|
||||
members[header[:16].decode().strip().rstrip("/")] = original[
|
||||
position + 60 : position + 60 + length
|
||||
]
|
||||
position += 60 + length + length % 2
|
||||
with tarfile.open(fileobj=io.BytesIO(members["control.tar.gz"]), mode="r:gz") as archive:
|
||||
control = archive.extractfile("control").read().decode()
|
||||
assert "Depends: mission-core-node (>= 0.8.0)" in control
|
||||
assert "Replaces: mission-core-node (<< 0.8.0)" in control
|
||||
|
||||
|
||||
def test_onboard_imports_run_from_declared_payload_without_core_checkout(modules, tmp_path):
|
||||
_, runtime, builder = modules
|
||||
stage = tmp_path / "installed"
|
||||
for path in runtime.files():
|
||||
target = stage / path.relative_to(ROOT)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(path, target)
|
||||
for relative in builder.RESOURCES:
|
||||
target = stage / relative
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(ROOT / relative, target)
|
||||
script = """
|
||||
import asyncio, pathlib, sys
|
||||
stage = pathlib.Path(sys.argv[1])
|
||||
sys.path.insert(0, str(stage / "src"))
|
||||
from k1link.device_plugins.xgrids_k1.node_bridge import NodeBridge
|
||||
assert "k1link.laboratory.execution" not in sys.modules
|
||||
assert "k1link.device_plugins.xgrids_k1.legacy_api" not in sys.modules
|
||||
assert "k1link.compute.jobs" not in sys.modules
|
||||
import k1link
|
||||
assert pathlib.Path(k1link.__file__).is_relative_to(stage)
|
||||
bridge = NodeBridge(stage)
|
||||
async def check():
|
||||
try:
|
||||
value = await bridge.state()
|
||||
assert value["phase"] == "idle"
|
||||
assert value["candidates"] == []
|
||||
for name, module in list(sys.modules.items()):
|
||||
if name.startswith("k1link.") and getattr(module, "__file__", None):
|
||||
assert pathlib.Path(module.__file__).is_relative_to(stage), name
|
||||
finally:
|
||||
bridge.service.close()
|
||||
asyncio.run(check())
|
||||
"""
|
||||
env = dict(os.environ, MISSIONCORE_DATA_DIR=str(tmp_path / "state"))
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-I", "-c", script, str(stage)],
|
||||
cwd=tmp_path,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=40,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
|
||||
def test_lab_lazy_exports_keep_the_public_objects_and_do_not_load_on_leaf_import(tmp_path):
|
||||
script = """
|
||||
import importlib, sys
|
||||
import k1link.laboratory as lab
|
||||
from k1link.laboratory.canonical_recorded_migration import matches_historical_recorded_projection
|
||||
assert "k1link.laboratory.execution" not in sys.modules
|
||||
assert set(lab.__all__) == set(lab._EXPORTS)
|
||||
for name in lab.__all__:
|
||||
assert getattr(lab, name) is getattr(importlib.import_module(lab._EXPORTS[name]), name)
|
||||
"""
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", script], cwd=tmp_path, capture_output=True, text=True, timeout=30
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
Reference in New Issue
Block a user