Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/images.yml
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ jobs:
sudo chown -R "$(id -u):$(id -g)" image-artifacts
python3 deploy/verify-image.py "image-artifacts/raft-dev-${{ matrix.architecture }}.tar.gz" --architecture '${{ matrix.architecture }}' --fingerprint "$fingerprint"
cd image-artifacts
sha256sum raft-dev-* > SHA256SUMS-${{ matrix.architecture }}
sha256sum raft-dev-* voxl-* > SHA256SUMS-${{ matrix.architecture }}
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: raft-dev-${{ matrix.architecture }}
Expand Down Expand Up @@ -216,7 +216,7 @@ jobs:
cd release-assets
sha256sum -c SHA256SUMS-arm64
sha256sum -c SHA256SUMS-amd64
sha256sum raft-*.whl raft-*.tar.gz microck-raft-*.tgz manifest-*.json packages-*.tsv npm-packages-*.json boot-*.json > SHA256SUMS
sha256sum raft-*.whl raft-*.tar.gz microck-raft-*.tgz manifest-*.json packages-*.tsv npm-packages-*.json boot-*.json voxl-* > SHA256SUMS
cd ..
python3 - <<'PY'
import os
Expand All @@ -239,4 +239,4 @@ jobs:
git config user.email "$(git show -s --format=%ae "$GITHUB_SHA")"
git tag -a "$RELEASE_TAG" "$GITHUB_SHA" -F release-notes.md
git push origin "refs/tags/$RELEASE_TAG"
gh release create "$RELEASE_TAG" release-assets/raft-*.whl release-assets/raft-*.tar.gz release-assets/microck-raft-*.tgz release-assets/SHA256SUMS release-assets/manifest-*.json release-assets/packages-*.tsv release-assets/npm-packages-*.json release-assets/boot-*.json --verify-tag --title "Raft $RELEASE_TAG" --notes-file release-notes.md --latest
gh release create "$RELEASE_TAG" release-assets/raft-*.whl release-assets/raft-*.tar.gz release-assets/microck-raft-*.tgz release-assets/SHA256SUMS release-assets/manifest-*.json release-assets/packages-*.tsv release-assets/npm-packages-*.json release-assets/boot-*.json release-assets/voxl-* --verify-tag --title "Raft $RELEASE_TAG" --notes-file release-notes.md --latest
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ see [defaults and host policies](docs/guides/config-policy.mdx).
## image and workspace control

the image builder starts with Debian 13 and installs language runtimes,
build tools, Docker, Chromium, FFmpeg and a noVNC desktop. build your own image
build tools, Docker, Chromium, FFmpeg and a Voxl desktop with optional noVNC. build your own image
or download an image for your host architecture from
[versioned releases](https://github.com/Microck/raft/releases). pin its fingerprint
in your configuration. see
Expand Down Expand Up @@ -168,7 +168,7 @@ records differences, sources and test coverage.
| Portable backup/recovery | ❓ | ✅ | Raft native archives; Boat whole-instance archive import is not established |
| Docker and development tools | ✅ | ✅ | Native ARM64 and AMD64 images. See [image tests](docs/boot-performance.mdx) |
| Private port forwarding | ✅ | ✅ | Local SSH tunnel |
| Desktop access | ✅ | ✅ | noVNC; see verification limits |
| Desktop access | ✅ | ✅ | Voxl by default; optional noVNC; see verification limits |
| Reverse forwarding | ✅ | ❌ | Not implemented |
| Public workspace hosting and browser-only streaming | ✅ | ❌ | Not implemented |
| Resize on resume/fork | ✅ | ✅ | Optional CPU/RAM overrides; disks keep their size |
Expand Down
11 changes: 5 additions & 6 deletions deploy/benchmark-boot.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,15 @@
time.sleep(0.1)
else: raise SystemExit('Docker did not become ready within 60 seconds')
"""
DESKTOP_READY = """import socket,time,urllib.request
DESKTOP_READY = """import time,urllib.request
end=time.monotonic()+30
while time.monotonic()<end:
try:
request=urllib.request.Request('http://127.0.0.1:6080/vnc.html',headers={'User-Agent':'OpenAI File Downloader, XaiImageApiFetch/1.0'})
with urllib.request.urlopen(request,timeout=1) as page: assert page.status==200
with socket.create_connection(('127.0.0.1',5900),1) as vnc: assert vnc.recv(12).startswith(b'RFB ')
request=urllib.request.Request('http://127.0.0.1:6080/screenshot',headers={'User-Agent':'OpenAI File Downloader, XaiImageApiFetch/1.0'})
with urllib.request.urlopen(request,timeout=1) as page: assert page.read(8)==b'\\x89PNG\\r\\n\\x1a\\n'
print('desktop-ready');break
except (OSError,AssertionError): time.sleep(0.1)
else: raise SystemExit('Desktop HTTP and VNC did not become ready within 30 seconds')
else: raise SystemExit('Desktop screenshot did not become ready within 30 seconds')
"""


Expand All @@ -57,7 +56,7 @@ def readiness(box, started, returned, desktop):
measured["dockerReadySeconds"] = time.monotonic() - started
if desktop:
desktop_started = time.monotonic()
live.execute(box, "systemctl", "start", "raft-desktop")
live.execute(box, "systemctl", "start", "raft-desktop-voxl")
live.execute(box, "python3", "-c", DESKTOP_READY)
measured["desktopStartSeconds"] = time.monotonic() - desktop_started
return {name: round(value, 3) for name, value in measured.items()}
Expand Down
14 changes: 13 additions & 1 deletion deploy/build-image.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,26 @@ def build(host, alias):
# Stage outside /tmp: its boot-time tmpfs mount can hide an early file push.
for source, guest, mode in [
("images/incus/build.sh", "/root/raft-build.sh", "755"),
("images/incus/install-voxl.sh", "/root/raft-install-voxl.sh", "755"),
("images/incus/desktop.sh", "/usr/local/bin/raft-desktop", "755"),
("images/incus/desktop-novnc.sh", "/usr/local/bin/raft-desktop-novnc", "755"),
("images/incus/raft-desktop.service", "/etc/systemd/system/raft-desktop.service", "644"),
(
"images/incus/raft-desktop-voxl.service",
"/etc/systemd/system/raft-desktop-voxl.service",
"644",
),
(
"images/incus/raft-desktop-novnc.service",
"/etc/systemd/system/raft-desktop-novnc.service",
"644",
),
]:
staged = "/tmp/" + Path(guest).name
copy(host, ROOT / source, staged)
remote(host, prefix + f" file push {staged} raft-builder{guest} --mode {mode}")
remote(host, prefix + " exec raft-builder -- bash /root/raft-build.sh")
remote(host, prefix + " exec raft-builder -- rm /root/raft-build.sh")
remote(host, prefix + " exec raft-builder -- rm /root/raft-build.sh /root/raft-install-voxl.sh")
remote(host, prefix + " stop raft-builder --timeout 30")
print(
remote(
Expand Down
77 changes: 75 additions & 2 deletions deploy/verify-image-tests.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Exercise the real image inspector with clean and credential-bearing tar archives."""
"""Exercise the image inspector with clean, licensing, and credential-bearing archives."""

import hashlib
import io
Expand All @@ -13,6 +13,42 @@
ROOT = Path(__file__).resolve().parent.parent


def source_fixture(omitted):
root = "voxl-server-0.3.2"
files = {
"README.txt": b"Fixture build instructions\n",
"install-voxl.sh": b"#!/bin/bash\n",
"build-environment.txt": b"arm64\nfixture compiler\n",
"native-packages.txt": b"x264=1.0\nlibyuv=1.0\nopus=1.0\npulseaudio=1.0\nlibxcb=1.0\n",
"pkgconfig/libyuv.pc": b"Name: fixture\n",
f"{root}/Cargo.toml": b'[package]\nname = "voxl-server"\nversion = "0.3.2"\n',
f"{root}/Cargo.lock": (
b'[[package]]\nname = "fixture-dependency"\nversion = "1.0"\n'
b'source = "registry+https://github.com/rust-lang/crates.io-index"\n'
),
f"{root}/LICENSE": b"MIT fixture\n",
f"{root}/src/main.rs": b"fn main() {}\n",
f"{root}/.cargo/config.toml": b'[source.vendored-sources]\ndirectory = "../vendor"\n',
"vendor/fixture-dependency-1.0/Cargo.toml": b'[package]\nname = "fixture-dependency"\n',
"vendor/fixture-dependency-1.0/.cargo-checksum.json": b'{"files":{}}\n',
}
for package in ["x264", "libyuv", "opus", "pulseaudio", "libxcb"]:
filename = f"{package}_1.0.tar.xz"
files[f"native/{package}_1.0.dsc"] = (
f"Checksums-Sha256:\n {'0' * 64} 7 {filename}\n".encode()
)
files[f"native/{filename}"] = b"fixture"
output = io.BytesIO()
with tarfile.open(fileobj=output, mode="w:gz") as archive:
for name, content in files.items():
if name == omitted:
continue
member = tarfile.TarInfo(name)
member.size = len(content)
archive.addfile(member, io.BytesIO(content))
return output.getvalue()


def verify():
with tempfile.TemporaryDirectory(prefix="raft-image-check-") as directory:
work = Path(directory)
Expand All @@ -22,6 +58,12 @@ def verify():
root = PurePosixPath("rootfs", "root")
cases = [
("clean", None, tarfile.REGTYPE, "", True),
("missing-voxl-source", None, tarfile.REGTYPE, "", False),
("missing-voxl-gpl", None, tarfile.REGTYPE, "", False),
("empty-voxl-gpl", None, tarfile.REGTYPE, "", False),
("corrupt-voxl-source", None, tarfile.REGTYPE, "", False),
("missing-vendored-dependency", None, tarfile.REGTYPE, "", False),
("missing-native-source", None, tarfile.REGTYPE, "", False),
("user-key", home / ".ssh/id_ed25519", tarfile.REGTYPE, "", False),
("root-key", root / ".ssh/id_ed25519", tarfile.REGTYPE, "", False),
("user-key-link", home / ".ssh/id_ed25519", tarfile.SYMTYPE, "/etc/passwd", False),
Expand Down Expand Up @@ -82,7 +124,25 @@ def verify():
"rootfs/etc/machine-id": b"",
"rootfs/opt/raft/packages.tsv": b"example\t1\n",
"rootfs/opt/raft/npm-packages.json": b"{}\n",
"rootfs/opt/raft/voxl-source.tar.gz": source_fixture(
{
"missing-vendored-dependency": "vendor/fixture-dependency-1.0/Cargo.toml",
"missing-native-source": "native/x264_1.0.tar.xz",
}.get(label)
),
"rootfs/opt/raft/voxl-GPL-2": b"GPL-2.0 fixture\n",
"rootfs/opt/raft/voxl-LICENSE": b"MIT fixture\n",
"rootfs/opt/raft/voxl-NOTICE": b"x264-linked executable: GPL-2.0\n",
"rootfs/opt/raft/voxl.tsv": b"voxl-server\t0.3.2\tfixture\n",
}
if label == "missing-voxl-source":
del resources["rootfs/opt/raft/voxl-source.tar.gz"]
if label == "missing-voxl-gpl":
del resources["rootfs/opt/raft/voxl-GPL-2"]
if label == "empty-voxl-gpl":
resources["rootfs/opt/raft/voxl-GPL-2"] = b""
if label == "corrupt-voxl-source":
resources["rootfs/opt/raft/voxl-source.tar.gz"] = b"not a gzip archive"
with tarfile.open(archive, "w:gz") as output:
for name, content in resources.items():
member = tarfile.TarInfo(name)
Expand Down Expand Up @@ -129,10 +189,23 @@ def verify():
assert (case / "packages-arm64.tsv").read_bytes() == resources[
"rootfs/opt/raft/packages.tsv"
]
elif label in {"missing-voxl-source", "missing-voxl-gpl"}:
assert "Missing template resources" in response.stderr, (label, response.stderr)
assert not (case / "manifest-arm64.json").exists()
elif label == "empty-voxl-gpl":
assert "Empty template resource" in response.stderr, (label, response.stderr)
assert not (case / "manifest-arm64.json").exists()
elif label in {
"corrupt-voxl-source",
"missing-vendored-dependency",
"missing-native-source",
}:
assert "Voxl source bundle" in response.stderr, (label, response.stderr)
assert not (case / "manifest-arm64.json").exists()
else:
assert "credential" in response.stderr.lower(), (label, response.stderr)
assert not (case / "manifest-arm64.json").exists()
print(f"Image inspector: {len(cases)} real clean/credential archive cases passed")
print(f"Image inspector: {len(cases)} clean/licensing/credential archive cases passed")


if __name__ == "__main__":
Expand Down
81 changes: 77 additions & 4 deletions deploy/verify-image.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
import os
from pathlib import Path, PurePosixPath
import re
import shutil
import tarfile
import tomllib


def credential_path(path):
Expand All @@ -19,6 +21,64 @@ def credential_path(path):
)


def verify_voxl_source(archive, version):
"""Check the source bundle's required rebuild inputs before publication."""
root = f"voxl-server-{version}"
required = {
"README.txt",
"install-voxl.sh",
"build-environment.txt",
"native-packages.txt",
"pkgconfig/libyuv.pc",
f"{root}/Cargo.toml",
f"{root}/Cargo.lock",
f"{root}/LICENSE",
f"{root}/.cargo/config.toml",
}
files = set()
native_packages = ""
native_sources = {}
lock = {"package": []}
try:
with tarfile.open(archive, "r|gz") as source:
for member in source:
path = PurePosixPath(member.name)
if path.is_absolute() or ".." in path.parts:
raise ValueError("Unsafe Voxl source member: " + member.name)
if member.isfile() and member.size:
name = str(path)
files.add(name)
if name == "native-packages.txt":
native_packages = source.extractfile(member).read().decode()
elif name.startswith("native/") and name.endswith(".dsc"):
native_sources[name] = source.extractfile(member).read().decode()
elif name == f"{root}/Cargo.lock":
lock = tomllib.loads(source.extractfile(member).read().decode())
for package in native_packages.splitlines():
name, package_version = package.split("=", 1)
dsc = f"native/{name}_{package_version.split(':')[-1]}.dsc"
required.add(dsc)
source_files = re.findall(
r"(?m)^ [0-9a-f]{64}\s+\d+\s+(\S+)$", native_sources.get(dsc, "")
)
if not source_files:
raise ValueError("Missing Debian source archive checksums: " + dsc)
required.update("native/" + name for name in source_files)
for package in lock["package"]:
if package.get("source"):
vendor = f"vendor/{package['name']}-{package['version']}"
required.update({f"{vendor}/Cargo.toml", f"{vendor}/.cargo-checksum.json"})
except (tarfile.TarError, OSError, UnicodeError, ValueError) as error:
raise ValueError(f"Invalid Voxl source bundle: {error}") from error
if (
not required <= files
or not any(name.startswith(f"{root}/src/") and name.endswith(".rs") for name in files)
or not any(name.startswith("vendor/") and name.endswith("/Cargo.toml") for name in files)
or not any(name.startswith("native/") and ".tar." in name for name in files)
):
raise ValueError("Incomplete Voxl source bundle: missing rebuild inputs")


def verify(archive, architecture, fingerprint):
if archive.stat().st_size >= 2 * 1024**3:
raise ValueError("Image exceeds GitHub's per-release-asset limit")
Expand All @@ -29,6 +89,11 @@ def verify(archive, architecture, fingerprint):
wanted = {
"rootfs/opt/raft/packages.tsv": f"packages-{architecture}.tsv",
"rootfs/opt/raft/npm-packages.json": f"npm-packages-{architecture}.json",
"rootfs/opt/raft/voxl-source.tar.gz": f"voxl-source-{architecture}.tar.gz",
"rootfs/opt/raft/voxl-GPL-2": f"voxl-GPL-2-{architecture}.txt",
"rootfs/opt/raft/voxl-LICENSE": f"voxl-LICENSE-{architecture}.txt",
"rootfs/opt/raft/voxl-NOTICE": f"voxl-NOTICE-{architecture}.txt",
"rootfs/opt/raft/voxl.tsv": f"voxl-{architecture}.tsv",
"metadata.yaml": None,
"rootfs/etc/machine-id": None,
}
Expand Down Expand Up @@ -59,14 +124,21 @@ def verify(archive, architecture, fingerprint):
):
raise ValueError("Credential setting in npm config: " + name)
if name in wanted:
content = image.extractfile(member).read()
found.add(name)
if name.endswith("machine-id") and content.strip():
if name.endswith("machine-id") and image.extractfile(member).read().strip():
raise ValueError("Template has a populated machine ID")
if wanted[name]:
(archive.parent / wanted[name]).write_bytes(content)
if not member.size:
raise ValueError("Empty template resource: " + name)
with (
image.extractfile(member) as source,
(archive.parent / wanted[name]).open("wb") as destination,
):
shutil.copyfileobj(source, destination)
if found != set(wanted):
raise ValueError("Missing template resources: " + str(set(wanted) - found))
version = (archive.parent / f"voxl-{architecture}.tsv").read_text().split("\t")[1]
verify_voxl_source(archive.parent / f"voxl-source-{architecture}.tar.gz", version)
manifest = {
"architecture": architecture,
"filename": archive.name,
Expand All @@ -78,7 +150,8 @@ def verify(archive, architecture, fingerprint):
"base": "Debian 13 default system container",
"isolation": "Unprivileged system container; shares the host kernel",
"checks": ["extended lifecycle", "31 developer tools", "same-host full-image recovery"],
"licensing": "Raft is MIT; bundled third-party packages retain their licenses in /usr/share/doc and upstream distributions",
"licensing": "Raft is MIT; the x264-linked Voxl executable is GPL-2.0. Its notices and corresponding source accompany the image and release. Other bundled packages retain their licenses in /usr/share/doc and upstream distributions.",
"voxlSource": f"voxl-source-{architecture}.tar.gz",
}
(archive.parent / f"manifest-{architecture}.json").write_text(
json.dumps(manifest, indent=2) + "\n"
Expand Down
Loading
Loading