Accept native SD cards for internal settings alongside legacy NVMe, reject USB ancestry and ambiguous disks, and preserve read-only boot loading with explicit recovery writes. Require built-in MMC drivers, validate cached kernel configuration, and provide SD-only, SD-first and USB-first EEPROM profiles. Expose typed create and inspect commands through fds-flash, reuse the existing cartridge creation code, and document the optional e2fsprogs package dependency. Extend storage, EEPROM, flashing and hardware-test coverage and advance the wiki reference to the published SD guide. Validation: rootfs checks for CLI/development, boot matrix, internal storage, EEPROM, flash, workstation, emulator, make check and wiki checks passed. The full internal suite passed on an unchanged rerun after one unexplained VM shutdown stall. Standalone init-test was blocked by its unavailable pinned upstream kernel. Physical Pi checks remain pending.
98 lines
5.9 KiB
Python
Executable File
98 lines
5.9 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Create a complete internal SD/NVMe GPT image as a new ordinary file."""
|
|
import argparse
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import uuid
|
|
|
|
project = Path(__file__).resolve().parents[1]
|
|
sys.path.insert(0, str(project / 'tools'))
|
|
from image_formats import digest, gpt, NAMESPACE, EFI_SYSTEM, LINUX_FILESYSTEM
|
|
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('--boot', type=Path, default=project / 'out/fds-boot.img')
|
|
parser.add_argument('--recovery', type=Path, default=project / 'out/fds-recovery.img')
|
|
parser.add_argument('--machine-config', type=Path, default=project / 'config/machine')
|
|
parser.add_argument('--internal-mib', type=int, default=256)
|
|
parser.add_argument('--recovery-mib', type=int, choices=[1024, 2048], default=1024)
|
|
parser.add_argument('--output-directory', type=Path)
|
|
args = parser.parse_args()
|
|
if not 32 <= args.internal_mib <= 65536:
|
|
parser.error('internal settings partition must be 32..65536 MiB')
|
|
boot, recovery = args.boot.resolve(strict=True), args.recovery.resolve(strict=True)
|
|
if not boot.is_file() or boot.stat().st_size != 512 * 1024 * 1024:
|
|
parser.error('boot input must be a 512 MiB regular FAT32 partition image')
|
|
if not recovery.is_file() or not 4096 <= recovery.stat().st_size <= args.recovery_mib * 1024 * 1024:
|
|
parser.error('recovery input must be a regular EROFS image fitting its partition')
|
|
with boot.open('rb') as stream:
|
|
header = stream.read(512)
|
|
if header[82:90] != b'FAT32 ' or header[71:82] != b'FDS_BOOT ' or header[510:] != b'\x55\xaa':
|
|
parser.error('boot input is not FDS_BOOT FAT32')
|
|
with recovery.open('rb') as stream:
|
|
stream.seek(1024)
|
|
header = stream.read(128)
|
|
if header[:4] != bytes.fromhex('e2e1f5e0') or header[64:80].rstrip(b'\0') != b'FDS_RECOVERY':
|
|
parser.error('recovery input is not FDS_RECOVERY EROFS')
|
|
work = args.output_directory or Path(tempfile.mkdtemp(prefix='internal-build.', dir=project / 'out'))
|
|
work = work.resolve(strict=True)
|
|
if not work.is_dir() or any(work.iterdir()):
|
|
parser.error('output directory must exist and be empty')
|
|
subprocess.run([str(project / 'tools/cargo-build'), '--locked', '--offline', '--release', '--target', 'x86_64-unknown-linux-gnu', '-p', 'fds-cli'], cwd=project, check=True)
|
|
host_cli = project / 'target/x86_64-unknown-linux-gnu/release/fds'
|
|
tree = work / 'settings'
|
|
(tree / 'config').mkdir(parents=True)
|
|
(tree / 'diagnostics').mkdir(mode=0o700)
|
|
subprocess.run([str(host_cli), 'machine', 'pack', str(args.machine_config.resolve(strict=True)), str(tree / 'config/machine.json')], check=True)
|
|
epoch = int(subprocess.check_output(['git', '-C', str(project / 'vendor/void-packages'), 'show', '-s', '--format=%ct', 'HEAD']))
|
|
for path in [tree, *sorted(tree.rglob('*'))]:
|
|
os.utime(path, (epoch, epoch))
|
|
runner = str(project / 'tools/in-image-tools')
|
|
subprocess.run([str(project / 'tools/prepare-image-tools')], check=True)
|
|
subprocess.run([runner, 'fsck.fat', '-n', str(boot)], check=True)
|
|
subprocess.run([runner, 'fsck.erofs', '--extract', str(recovery)], check=True)
|
|
settings = work / 'internal.ext4'
|
|
with settings.open('xb') as stream:
|
|
stream.truncate(args.internal_mib * 1024 * 1024)
|
|
identity = uuid.uuid5(NAMESPACE, 'internal:' + digest(tree / 'config/machine.json'))
|
|
# Source ownership is root inside this unprivileged user namespace, independent
|
|
# of the workstation UID. No workstation block devices are exposed.
|
|
environment = {**os.environ, 'E2FSPROGS_FAKE_TIME': str(epoch)}
|
|
subprocess.run(['bwrap', '--unshare-user', '--uid', '0', '--gid', '0', '--ro-bind', '/', '/',
|
|
'--bind', str(work), str(work), '--dev', '/dev', '--proc', '/proc',
|
|
runner, 'mke2fs', '-q', '-t', 'ext4', '-F', '-b', '4096', '-m', '0',
|
|
'-L', 'FDS_INTERNAL', '-U', str(identity),
|
|
'-E', f'root_owner=0:0,lazy_itable_init=0,lazy_journal_init=0,hash_seed={identity}',
|
|
'-d', str(tree), str(settings)], env=environment, check=True)
|
|
# mke2fs copies source ctimes; normalize every populated inode after construction.
|
|
commands = work / 'normalize.debugfs'
|
|
commands.write_text(''.join(f'set_inode_field {name} {field} @{epoch}\n'
|
|
for name in ['/', '/lost+found', '/config', '/config/machine.json', '/diagnostics']
|
|
for field in ['atime', 'ctime', 'mtime', 'crtime']))
|
|
subprocess.run([runner, 'debugfs', '-w', '-f', str(commands), str(settings)], env=environment, check=True)
|
|
subprocess.run([runner, 'e2fsck', '-f', '-n', str(settings)], env=environment, check=True)
|
|
padded = work / 'recovery-partition.erofs'
|
|
shutil.copyfile(recovery, padded)
|
|
with padded.open('r+b') as stream:
|
|
stream.truncate(args.recovery_mib * 1024 * 1024)
|
|
image = work / 'internal.img'
|
|
layout = gpt(image, [('FDS_BOOT', EFI_SYSTEM, boot), ('FDS_RECOVERY', LINUX_FILESYSTEM, padded), ('FDS_INTERNAL', LINUX_FILESYSTEM, settings)])
|
|
layout.update(format=1, image_sha256=digest(image), machine_settings_sha256=digest(tree / 'config/machine.json'),
|
|
recovery_payload_sha256=digest(recovery), boot_payload_sha256=digest(boot))
|
|
observed = json.loads(subprocess.check_output(['sfdisk', '--json', str(image)]))['partitiontable']
|
|
assert observed['label'] == 'gpt' and len(observed['partitions']) == 3
|
|
for expected, actual in zip(layout['partitions'], observed['partitions']):
|
|
assert (actual['name'], actual['start'], actual['size']) == (expected['name'], expected['start'], expected['size'])
|
|
subprocess.run(['sfdisk', '--verify', str(image)], check=True)
|
|
(work / 'layout.json').write_text(json.dumps(layout, indent=2) + '\n')
|
|
if args.output_directory is None:
|
|
link = project / 'out/fds-internal.img.next'
|
|
link.symlink_to(work.name + '/internal.img')
|
|
link.replace(project / 'out/fds-internal.img')
|
|
print(f'PASS: complete internal SD/NVMe image, three verified GPT payloads: {work}')
|
|
print('SKIP: no physical disk was written; Pi firmware/SD boot requires hardware')
|