makepad/tools/arch_usb/clone_assets.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

190 lines
9.6 KiB
Python

"""Refresh the USB's offline assets from an installed clone, without installs."""
import base64
import hashlib
import json
from pathlib import Path
import re
import shlex
import shutil
import subprocess
import tarfile
import tempfile
HERE = Path(__file__).resolve().parent
def digest(path):
with path.open('rb') as stream:
return hashlib.file_digest(stream, 'sha256').hexdigest()
def ssh(host, *command):
if not re.fullmatch(r'(?:[a-zA-Z0-9_.-]+@)?[a-zA-Z0-9][a-zA-Z0-9_.-]*', host):
raise ValueError('Use a known SSH hostname or user@hostname for --clone')
return ['ssh', '-T', '-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=yes',
'-o', 'UpdateHostKeys=no', '-o', 'ConnectTimeout=10', host, shlex.join(command)]
def read_database(path, repo):
records = {}
with tarfile.open(path, 'r:*') as archive:
for member in archive:
if not member.name.endswith('/desc'): continue
record = {'repo': repo}
for section in archive.extractfile(member).read().decode().strip().split('\n\n'):
lines = section.splitlines()
record[lines[0].strip('%')] = lines[1:]
records[record['NAME'][0]] = record
return records
def refresh_packages(assets, host, requested):
"""Fill gaps only from matching signed archives; retain one repo snapshot."""
mirror = assets/'package-mirror'
database = {}
for repo in ('core', 'extra'):
path = mirror/repo/'os/x86_64'/f'{repo}.db'
remote = subprocess.check_output(ssh(host, 'sha256sum', f'/var/lib/pacman/sync/{repo}.db'), text=True).split()[0]
if digest(path) != remote:
raise ValueError(f'{repo}.db differs on the clone; refusing to mix package snapshots')
database.update(read_database(path, repo))
selected = {p['NAME'][0]: p for p in json.loads((assets/'resolved-packages.json').read_text())}
for name, package in selected.items():
if any(package.get(key) != database[name].get(key) for key in ('VERSION', 'FILENAME', 'SHA256SUM')):
raise ValueError(f'Cached metadata differs from its repository snapshot: {name}')
original_names = set(selected)
queue = list(requested)
while queue:
name = queue.pop(0)
if name in selected: continue
package = database[name]
selected[name] = package
for expression in package.get('DEPENDS', []):
dependency = re.split('[<>=]', expression)[0]
if dependency in selected or any(
dependency == re.split('[<>=]', provided)[0]
for item in selected.values() for provided in item.get('PROVIDES', [])):
continue
if dependency in database:
queue.append(dependency)
else:
providers = [n for n, item in database.items() if any(
dependency == re.split('[<>=]', value)[0] for value in item.get('PROVIDES', []))]
if len(providers) != 1:
raise ValueError(f'Choose a provider for {name}: {expression}: {providers}')
queue.extend(providers)
changed = set(selected) != original_names
with tempfile.TemporaryDirectory(prefix='clone-packages-', dir=assets) as temp:
stage = Path(temp)
for name, package in sorted(selected.items()):
filename = package['FILENAME'][0]
path = mirror/package['repo']/'os/x86_64'/filename
signature = base64.b64decode(package['PGPSIG'][0])
for suffix in ('', '.sig'):
target = path.with_name(filename+suffix)
valid = target.is_file() and (
target.read_bytes() == signature if suffix else digest(target) == package['SHA256SUM'][0])
if valid: continue
# The clone retains both the original mirror and later pacman
# downloads. Neither path executes or installs the package.
candidates = [f'/var/cache/pacman/pkg/{filename}{suffix}',
f'/var/cache/makepad-mirror/{package["repo"]}/os/x86_64/{filename}{suffix}']
fetched = stage/(filename+suffix)
for remote in candidates:
with fetched.open('wb') as stream:
result = subprocess.run(ssh(host, 'cat', remote), stdout=stream, stderr=subprocess.PIPE)
if result.returncode == 0: break
else:
raise ValueError(f'Package missing from the installed clone: {filename}{suffix}')
if (fetched.read_bytes() != signature if suffix else digest(fetched) != package['SHA256SUM'][0]):
raise ValueError(f'Clone archive/signature differs from the snapshot: {filename}{suffix}')
target.parent.mkdir(parents=True, exist_ok=True)
fetched.replace(target)
changed = True
print(f'Verified {len(selected)} cached packages and their signature files.', flush=True)
if not changed: return
# Stage the complete replacement before changing the seed's manifest.
tar_path = stage/'mirror.tar'
subprocess.run(['tar', '-cf', str(tar_path), '-C', str(mirror), '.'], check=True)
chunks = []
with tar_path.open('rb') as source:
index = 0
while block := source.read(4*1024*1024):
name = f'mirror.tar.{chr(97+index//26)}{chr(97+index%26)}'
path = stage/name
with path.open('wb') as output:
remaining = 1024**3
while block:
output.write(block)
remaining -= len(block)
if not remaining: break
block = source.read(min(remaining, 4*1024*1024))
chunks.append(path)
index += 1
seed = assets/'seed'
sums = {name: value for value, name in (line.split(' ', 1) for line in (seed/'SHA256SUMS').read_text().splitlines())}
sums = {name: value for name, value in sums.items() if not name.startswith('mirror.tar.')}
for chunk in chunks:
sums[chunk.name] = digest(chunk)
for old in seed.glob('mirror.tar.*'): old.unlink()
for chunk in chunks: chunk.replace(seed/chunk.name)
(seed/'SHA256SUMS').write_text(''.join(f'{value} {name}\n' for name, value in sorted(sums.items())))
(assets/'resolved-packages.json').write_text(json.dumps([selected[n] for n in sorted(selected)], indent=2)+'\n')
print('Refreshed the offline mirror and its manifest.', flush=True)
def refresh_source(assets, host, root):
with tempfile.TemporaryDirectory(prefix='clone-source-', dir=assets) as temp:
stage = Path(temp)
payload = stage/'payload.tgz'
print(f'Exporting the working source, WM/AI Hub and Cargo cache from {host}:{root}.', flush=True)
with payload.open('wb') as output:
subprocess.run(ssh(host, 'python3', '-', root),
input=(HERE/'clone_export.py').read_bytes(), stdout=output, check=True)
unpacked = stage/'unpacked'
unpacked.mkdir()
with tarfile.open(payload, 'r:gz') as archive:
archive.extractall(unpacked, filter='data')
# The delivered source must also retain this regeneration entry point,
# even when the working clone predates it.
builder = unpacked/'source/tools/arch_usb'
builder.mkdir(parents=True, exist_ok=True)
for path in HERE.iterdir():
if path.is_file() and path.suffix in ('.py', '.sh', '.m', '.env', '.rules', '.service', '.txt'):
shutil.copy2(path, builder/path.name)
manifest = json.loads((unpacked/'manifest.json').read_text())
manifest['host'] = host
for name, expected in manifest['binary_sha256'].items():
if name not in ('wm', 'makepad-ai-hub') or digest(unpacked/'binaries'/name) != expected:
raise ValueError(f'Exported service binary hash mismatch: {name}')
source = stage/'prepared'
source.mkdir()
with tarfile.open(source/'makepad-source.tgz', 'w:gz', compresslevel=3) as archive:
archive.add(unpacked/'source', arcname='.')
with tarfile.open(source/'cargo-cache.tar', 'w') as archive:
archive.add(unpacked/'cargo', arcname='.')
shutil.copytree(unpacked/'binaries', source/'binaries')
shutil.copytree(unpacked/'config', source/'config')
for path in (source/'config').glob('*.sh'):
subprocess.run(['/bin/bash', '-n', str(path)], check=True)
revision = f'Installed clone: {host}:{root}\nExported: {manifest["exported_at"]}\nFiles: {manifest["source_files"]}\n'
(source/'source-revision.txt').write_text(revision)
manifest['files'] = {str(path.relative_to(source)): digest(path) for path in source.rglob('*') if path.is_file()}
(source/'manifest.json').write_text(json.dumps(manifest, indent=2)+'\n')
target = assets/'clone'
if target.exists():
if not (target/'manifest.json').is_file():
raise ValueError(f'Refusing to replace an unrecognized clone cache: {target}')
shutil.rmtree(target)
source.replace(target)
print('Installed clone snapshot cached and verified.', flush=True)
def verify_source(assets):
source = assets/'clone'
manifest = json.loads((source/'manifest.json').read_text())
for name, expected in manifest['files'].items():
path = source/name
if not path.resolve().is_relative_to(source.resolve()) or digest(path) != expected:
raise ValueError(f'Cached clone changed: {name}')
return source, manifest