Package onboard K1 separately with a private portable installer

This commit is contained in:
DCCONSTRUCTIONS
2026-09-07 11:43:24 +03:00
parent 111fe3dcfb
commit d8cc5367c4
30 changed files with 1166 additions and 178 deletions
-1
View File
@@ -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}
+7 -52
View File
@@ -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)
-6
View File
@@ -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
+79
View File
@@ -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
+187
View File
@@ -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)
+12
View File
@@ -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()
+16
View File
@@ -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
+7
View File
@@ -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.
+22
View File
@@ -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)
+12
View File
@@ -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,
)
)
+1 -1
View File
@@ -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": [
+49
View File
@@ -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),
)
+79 -34
View File
@@ -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__})
+224
View File
@@ -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