makepad/tools/arch_usb/restore_models.py
Admin 384d0e031c tools: the Builder replaces makepad_loader, the web server moves to makepad/webserver, fleet scripts, docs and the workspace members
tools/makepad_builder replaces tools/makepad_loader: one build target
shared across app builds, workspace package selection, checkout
progress on the public Git API, detached built apps with a completion
state, waits for Windows security scans, manual retry after compiler
locks, dedicated-folder installer checks, catalog and runtime fixes.
tools/web_server and its scripts leave for github.com/makepad/webserver.
Arch USB clone/restore scripts, the qwen38 box scripts and the G-belt
serial test join tools/. docs/agents records the agent workflow and the
remote-control handoff protocol; AGENTS.md forbids vendored sources and
bulk imports. Cargo.toml lists apps/wm-dyn, libs/code_language,
libs/search, libs/tar, libs/loader_bundle and tools/makepad_builder,
and drops the two removed crates.

Squashed from work:
- Share Builder target across Makepad app builds
- Fix Builder workspace package selection
- Align Builder checkout progress with public Git API
- Detach built apps and show completion state
- Wait for Windows security scans
- Offer manual retry after Windows compiler locks
- docs: the agent workflow of record and the remote-control handoff protocol
- builder: dedicated-folder installer checks, catalog and runtime fixes; Windows job objects hold c_void handles
- tools: Arch USB clone/restore scripts, the qwen38 box scripts, and the G-belt serial test
- tools: the web server moves to makepad/webserver
- AGENTS.md: no vendored sources or bulk imports in the tree

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 12:17:59 +02:00

194 lines
9.9 KiB
Python

#!/usr/bin/env python3
"""Restore an installed clone's model trees to an already mounted Btrfs USB.
Run as root on the Linux clone. This does not format or repartition anything.
Example: sudo python3 restore_models.py --destination /run/makepad-usb \
--seed /run/makepad-usb-seed --serial 03005423082225201936
The Btrfs send/receive copy retains shared extents, so repeated model files
do not expand beyond the USB's capacity. No model download is performed.
"""
import argparse
import json
import os
from pathlib import Path
import shutil
import stat
import subprocess
import time
def run(*args, **kwargs):
return subprocess.run([str(arg) for arg in args], check=True, **kwargs)
def output(*args):
return subprocess.check_output([str(arg) for arg in args], text=True).strip()
def inventory(root):
result = {}
for parent, directories, files in os.walk(root, followlinks=False):
for name in sorted(directories + files):
path = Path(parent)/name
info = path.lstat()
if stat.S_ISLNK(info.st_mode):
entry = ['link', os.readlink(path)]
elif stat.S_ISREG(info.st_mode):
entry = ['file', info.st_size]
elif stat.S_ISDIR(info.st_mode):
entry = ['directory']
else:
raise ValueError(f'Unexpected model file type: {path}')
result[str(path.relative_to(root))] = entry
return result
def check_links(root, weights):
count = 0
for name, entry in inventory(weights).items():
if entry[0] != 'link': continue
logical = Path('/home/arch/.makepad/weights')/name
target = Path(entry[1])
if not target.is_absolute(): target = logical.parent/target
target = Path(os.path.normpath(target))
if not target.is_relative_to('/home/arch'):
raise ValueError(f'Model link leaves the cloned home: {name} -> {target}')
# Absolute targets must be checked inside the USB, not the live host.
mapped = root/target.relative_to('/')
if not mapped.resolve().is_relative_to(root.resolve()) or not mapped.is_file():
raise ValueError(f'Model link does not resolve inside {root}: {name} -> {target}')
count += 1
return count
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument('--destination', type=Path, required=True)
parser.add_argument('--seed', type=Path, required=True)
parser.add_argument('--serial', required=True, help='Expected external USB serial from lsblk')
parser.add_argument('--source-home', type=Path, default=Path('/home/arch'))
args = parser.parse_args()
if os.geteuid() != 0: parser.error('Run as root on the Linux clone')
destination = args.destination.resolve(strict=True)
seed = args.seed.resolve(strict=True)
home = args.source_home.resolve(strict=True)
mount = json.loads(output('findmnt', '-J', '-M', destination))['filesystems'][0]
if mount['fstype'] != 'btrfs' or destination == Path('/'):
raise ValueError('Destination must be a separately mounted Btrfs USB root')
device = mount['source'].split('[', 1)[0]
parent = output('lsblk', '-ndo', 'PKNAME', device)
if not parent or output('lsblk', '-ndo', 'TRAN', '/dev/'+parent) != 'usb':
raise ValueError('Destination is not on a USB disk')
if output('lsblk', '-ndo', 'SERIAL', '/dev/'+parent) != args.serial:
raise ValueError('USB serial does not match the selected drive')
seed_mount = json.loads(output('findmnt', '-J', '-M', seed))['filesystems'][0]
if output('lsblk', '-ndo', 'PKNAME', seed_mount['source']) != parent or seed_mount['fstype'] != 'iso9660':
raise ValueError('Seed must be the read-only ISO partition on the same USB')
if not (seed/'clone.json').is_file() or not (seed/'ssh.sh').is_file():
raise ValueError('USB seed lacks the cloned runtime and new SSH setup')
if output('findmnt', '-no', 'FSTYPE', '-T', home) != 'btrfs':
raise ValueError('Source models must be on Btrfs to retain shared extents')
model_destination = destination/'home/arch/models'
weights_destination = destination/'home/arch/.makepad/weights'
for path in (model_destination, weights_destination):
if path.exists(): raise ValueError(f'Refusing to replace an existing model tree: {path}')
models = home/'models'
weights = home/'.makepad/weights'
check_links(Path('/'), weights)
run('btrfs', 'filesystem', 'resize', 'max', destination)
usage = output('btrfs', 'filesystem', 'du', '-s', '--raw', models).splitlines()[-1].split()
required = int(usage[1]) + int(usage[2]) + 35_000_000_000
free = shutil.disk_usage(destination).free
if free < required:
raise ValueError(f'Models plus OS reserve need {required} bytes; USB has {free} free')
state = Path('/var/lib/makepad-model-transfer')
stage = state/'payload'
receive_directory = destination/'var/lib/makepad-model-transfer'
if stage.exists() or receive_directory.exists():
raise ValueError('A previous transfer exists; inspect it before restarting')
state.mkdir(parents=True, exist_ok=True)
receive_directory.mkdir(parents=True)
run('btrfs', 'subvolume', 'create', stage)
print('Creating a stable model snapshot using shared extents.', flush=True)
run('cp', '-a', '--reflink=always', models, stage/'models')
run('cp', '-a', '--reflink=always', weights, stage/'weights')
# Each booted machine generates its own AI Hub identity and lock file.
for name in ('node-key', 'service.lock'):
(stage/'weights'/name).unlink(missing_ok=True)
run('btrfs', 'property', 'set', '-ts', stage, 'ro', 'true')
expected = {'models': inventory(stage/'models'), 'weights': inventory(stage/'weights')}
print(f'Model snapshot ready; about {required-35_000_000_000:,} unique bytes plus model links.', flush=True)
(state/'inventory.json').write_text(json.dumps(expected, indent=2)+'\n')
started = time.monotonic()
last = started
transferred = 0
sender = subprocess.Popen(['btrfs', 'send', '--proto', '2', '--compressed-data', str(stage)], stdout=subprocess.PIPE)
receiver = subprocess.Popen(['btrfs', 'receive', str(receive_directory)], stdin=subprocess.PIPE)
try:
while block := sender.stdout.read(4*1024*1024):
receiver.stdin.write(block)
transferred += len(block)
now = time.monotonic()
if now-last >= 10:
print(f'Transferred {transferred/1e9:.1f} GB in {now-started:.0f}s ({transferred/(now-started)/1e6:.0f} MB/s).', flush=True)
last = now
receiver.stdin.close()
if sender.wait() != 0 or receiver.wait() != 0:
raise RuntimeError('Btrfs model transfer failed; source snapshot retained')
finally:
for process in (sender, receiver):
if process.poll() is None:
process.terminate()
process.wait()
received = receive_directory/'payload'
for name in ('models', 'weights'):
if inventory(received/name) != expected[name]:
raise ValueError(f'Received {name} inventory differs from the source snapshot')
model_destination.parent.mkdir(parents=True, exist_ok=True)
weights_destination.parent.mkdir(parents=True, exist_ok=True)
owner = home.stat()
for path in (model_destination.parent, weights_destination.parent):
os.chown(path, owner.st_uid, owner.st_gid)
run('cp', '-a', '--reflink=always', received/'models', model_destination)
run('cp', '-a', '--reflink=always', received/'weights', weights_destination)
for name, path in (('models', model_destination), ('weights', weights_destination)):
if inventory(path) != expected[name]:
raise ValueError(f'Installed {name} inventory differs from the source snapshot')
links = check_links(destination, weights_destination)
print(f'Model paths and sizes match; all {links} model links resolve inside the USB.', flush=True)
# Install and enable the same units that first-boot provisioning uses.
# Conditions keep them inactive until package setup has completed.
for unit in ('makepad-wm.service', 'makepad-aihub.service'):
target = destination/'etc/systemd/system'/unit
shutil.copyfile(seed/unit, target)
target.chmod(0o644)
run('systemctl', '--root='+str(destination), 'enable', unit)
run('systemctl', '--root='+str(destination), 'is-enabled', unit)
for name in ('wm-session.sh', 'aihub-session.sh'):
target = destination/'usr/local/bin'/('makepad-'+name.removesuffix('.sh'))
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(seed/name, target)
target.chmod(0o755)
config = destination/'etc/makepad'
config.mkdir(parents=True, exist_ok=True)
for name in ('wm.env', 'aihub.env'):
shutil.copyfile(seed/name, config/name)
run('btrfs', 'subvolume', 'delete', received)
receive_directory.rmdir()
run('btrfs', 'filesystem', 'sync', destination)
print('Reading back the USB filesystem with a read-only Btrfs scrub.', flush=True)
run('btrfs', 'scrub', 'start', '-B', '-r', destination)
report = {'serial': args.serial, 'stream_bytes': transferred, 'model_links': links,
'model_files': sum(e[0] == 'file' for e in expected['models'].values()),
'model_logical_bytes': sum(e[1] for e in expected['models'].values() if e[0] == 'file'),
'wm_autostart': True, 'aihub_autostart': True,
'completed_at': time.strftime('%Y-%m-%dT%H:%M:%S%z')}
path = destination/'var/lib/makepad-model-restore.json'
path.write_text(json.dumps(report, indent=2)+'\n')
run('btrfs', 'filesystem', 'sync', destination)
run('btrfs', 'subvolume', 'delete', stage)
print('Model restoration verified. WM and AI Hub are enabled for boot.', flush=True)
if __name__ == '__main__':
main()