Support safe installation into existing GPT partitions and Btrfs layouts
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
"""Read-only validation and explicit storage plans for the installer.
|
||||
|
||||
Only plain GPT partitions on physical disks are supported. This module never
|
||||
selects a target or runs a partition/format command while constructing a plan.
|
||||
"""
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path, PurePosixPath
|
||||
import re
|
||||
|
||||
ESP_TYPE = 'c12a7328-f81f-11d2-ba4b-00a0c93ec93b'
|
||||
UUID = re.compile(r'[A-Fa-f0-9]+(?:-[A-Fa-f0-9]+)+\Z')
|
||||
PARTUUID = re.compile(r'[A-Fa-f0-9]{8}(?:-[A-Fa-f0-9]{4}){3}-[A-Fa-f0-9]{12}\Z')
|
||||
ESP_RESERVE = 64 * 1024**2
|
||||
MIN_ROOT = 4 * 1024**3
|
||||
|
||||
|
||||
def fail(message):
|
||||
from .installer import InstallError
|
||||
raise InstallError(message)
|
||||
|
||||
|
||||
def ancestors(topo, path):
|
||||
seen, pending = set(), list(topo.parents[path])
|
||||
while pending:
|
||||
current = pending.pop()
|
||||
if current not in seen:
|
||||
seen.add(current)
|
||||
pending.extend(topo.parents[current] - seen)
|
||||
return seen
|
||||
|
||||
|
||||
def fingerprint(node):
|
||||
return tuple(node.get(key) for key in
|
||||
('path', 'type', 'size', 'maj:min', 'start', 'partuuid', 'parttype', 'fstype', 'uuid'))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DiskPlan:
|
||||
target: str
|
||||
size: int
|
||||
identity: tuple
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PartitionPlan:
|
||||
root: str
|
||||
esp: str
|
||||
root_parent: str
|
||||
esp_parent: str
|
||||
esp_number: int
|
||||
root_size: int
|
||||
root_partuuid: str
|
||||
esp_partuuid: str
|
||||
esp_uuid: str
|
||||
identity: tuple
|
||||
|
||||
|
||||
def partition_plan(request, probe, topo):
|
||||
from .installer import DEVICE
|
||||
paths = []
|
||||
for chosen in (request.root_partition, request.esp_partition):
|
||||
if not isinstance(chosen, str) or not DEVICE.fullmatch(chosen) or '..' in PurePosixPath(chosen).parts:
|
||||
fail('Choose explicit /dev root and ESP partitions')
|
||||
canonical = probe.resolve(chosen)
|
||||
if not DEVICE.fullmatch(canonical):
|
||||
fail('Invalid canonical partition path')
|
||||
paths.append(canonical)
|
||||
root, esp = paths
|
||||
if root == esp:
|
||||
fail('Root and ESP must be distinct partitions')
|
||||
if request.execute and request.confirm_format != root:
|
||||
fail(f'Execution requires --confirm-format {root} exactly')
|
||||
parents, numbers, identities = [], [], []
|
||||
for path in paths:
|
||||
node = topo.nodes.get(path)
|
||||
if not node or node['type'] != 'part' or len(topo.parents[path]) != 1:
|
||||
fail('Target must be a plain partition with exactly one physical backing disk')
|
||||
parent = next(iter(topo.parents[path]))
|
||||
if topo.nodes[parent]['type'] != 'disk' or topo.parents[parent] or not probe.physical_disk(parent):
|
||||
fail('Nested or virtual partition backing is unsupported')
|
||||
if topo.edges[path] - {parent}:
|
||||
fail('Selected partition has active holders or nested devices')
|
||||
if any(source == path or source in ancestors(topo, path) or path in ancestors(topo, source)
|
||||
for source in topo.protected):
|
||||
fail(f'Selected partition backs mounted/root/live storage: {path}')
|
||||
if not PARTUUID.fullmatch(node.get('partuuid') or ''):
|
||||
fail('Missing or ambiguous GPT partition identity')
|
||||
# Check every sibling extent; never infer bounds from partition names.
|
||||
siblings = [p for p in topo.nodes if parent in topo.parents[p]]
|
||||
extents = []
|
||||
for sibling in siblings:
|
||||
child = topo.nodes[sibling]
|
||||
start = child.get('start')
|
||||
if child['type'] != 'part' or topo.parents[sibling] != {parent} or isinstance(start, bool) or not isinstance(start, int) or start <= 0:
|
||||
fail('Ambiguous partition bounds or nested topology')
|
||||
end = start * 512 + child['size']
|
||||
if child['size'] <= 0 or end > topo.nodes[parent]['size']:
|
||||
fail('Partition extends beyond its backing disk')
|
||||
extents.append((start*512, end, sibling))
|
||||
extents.sort()
|
||||
if any(left[1] > right[0] for left, right in zip(extents, extents[1:])):
|
||||
fail('Overlapping partition bounds')
|
||||
# The partition number is provided by the kernel, not derived from an alias.
|
||||
number = probe.partition_number(path, node)
|
||||
if number <= 0:
|
||||
fail('Missing kernel partition number')
|
||||
probe.verify_identity(path, node)
|
||||
probe.verify_identity(parent, topo.nodes[parent])
|
||||
parents.append(parent); numbers.append(number)
|
||||
identities.append((fingerprint(node), fingerprint(topo.nodes[parent]),
|
||||
tuple(sorted((sibling, fingerprint(topo.nodes[sibling])) for sibling in siblings))))
|
||||
if topo.nodes[root]['size'] < MIN_ROOT:
|
||||
fail('Root partition is smaller than the minimum 4 GiB')
|
||||
esp_node = topo.nodes[esp]
|
||||
if esp_node.get('fstype') != 'vfat' or (esp_node.get('parttype') or '').lower() != ESP_TYPE:
|
||||
fail('ESP must be an existing FAT GPT EFI System Partition')
|
||||
if not UUID.fullmatch(esp_node.get('uuid') or ''):
|
||||
fail('ESP filesystem UUID missing')
|
||||
# Equal GPT IDs anywhere would make later PARTUUID recovery ambiguous.
|
||||
ids = [n.get('partuuid', '').lower() for n in topo.nodes.values() if n.get('partuuid')]
|
||||
if len(ids) != len(set(ids)):
|
||||
fail('Duplicate GPT partition identities')
|
||||
return PartitionPlan(root, esp, *parents, numbers[1], topo.nodes[root]['size'],
|
||||
topo.nodes[root]['partuuid'], esp_node['partuuid'], esp_node['uuid'], tuple(identities))
|
||||
|
||||
|
||||
def format_root(root, filesystem):
|
||||
return (['mkfs.btrfs', '-f', '-L', 'HYDRA_ROOT', root] if filesystem == 'btrfs' else
|
||||
['mkfs.ext4', '-F', '-L', 'HYDRA_ROOT', root])
|
||||
|
||||
|
||||
def payload_capacity(listing):
|
||||
"""Sum uncompressed inode sizes from unsquashfs numeric long listing."""
|
||||
allocated, inodes = 0, 0
|
||||
for line in listing.splitlines():
|
||||
match = re.match(r'^[-dlcbps][rwxstST-]{9}\s+\d+/\d+\s+(\d+)\s+\d{4}-\d\d-\d\d\s+\d\d:\d\d\s+', line)
|
||||
if match:
|
||||
size = int(match[1])
|
||||
allocated += ((size + 4095) // 4096) * 4096 + 4096
|
||||
inodes += 1
|
||||
elif re.match(r'^[cb][rwxstST-]{9}\s+\d+/\d+\s+\d+,\s*\d+\s+\d{4}-\d\d-\d\d\s+\d\d:\d\d\s+', line):
|
||||
allocated += 4096
|
||||
inodes += 1
|
||||
elif line and not line.startswith(('Parallel unsquashfs:', 'Number of processors', 'squashfs-root')):
|
||||
fail('Cannot determine uncompressed payload capacity')
|
||||
if not inodes:
|
||||
fail('Cannot determine uncompressed payload capacity')
|
||||
# Metadata, allocated blocks, generated initramfs, and initial user state.
|
||||
return max(MIN_ROOT, allocated * 12 // 10 + 1024**3)
|
||||
|
||||
|
||||
def foreign_efi_hashes(directory, *, ignore_owned=False):
|
||||
"""Fingerprint all foreign regular files; reject ambiguous/special paths."""
|
||||
root = Path(directory)
|
||||
result = {}
|
||||
for path in sorted(root.rglob('*')):
|
||||
relative = path.relative_to(root).as_posix()
|
||||
if ignore_owned and (relative.casefold() == 'efi/hydraos' or relative.casefold().startswith('efi/hydraos/')):
|
||||
continue
|
||||
if path.is_symlink() or not (path.is_file() or path.is_dir()):
|
||||
fail('ESP contains unsupported special files')
|
||||
if path.is_file():
|
||||
with path.open('rb') as source:
|
||||
digest = hashlib.file_digest(source, 'sha256').hexdigest()
|
||||
result[relative] = digest
|
||||
return result
|
||||
|
||||
|
||||
def inspect_esp(directory):
|
||||
root = Path(directory)
|
||||
for entry in root.iterdir():
|
||||
if entry.name.casefold() == 'efi':
|
||||
if not entry.is_dir() or entry.is_symlink():
|
||||
fail('ESP EFI directory is not an ordinary directory')
|
||||
if any(p.name.casefold() == 'hydraos' for p in entry.iterdir()):
|
||||
fail('ESP already contains EFI/HydraOS; refusing loader collision')
|
||||
info = os.statvfs(root)
|
||||
if info.f_bavail * info.f_frsize < ESP_RESERVE:
|
||||
fail('ESP needs at least 64 MiB free for the Hydra EFI loader')
|
||||
return foreign_efi_hashes(root)
|
||||
+228
-37
@@ -1,4 +1,4 @@
|
||||
"""Explicit whole-disk installer. Importing this module never mutates storage.
|
||||
"""Explicit whole-disk and existing-partition installer. Importing this module never mutates storage.
|
||||
|
||||
Live payload contract: /etc/hydra/live-mode/active and remove-on-install.json
|
||||
must be regular files. The manifest is a JSON array of absolute paths matching
|
||||
@@ -23,8 +23,12 @@ import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import warnings
|
||||
import uuid
|
||||
|
||||
from typing import TextIO
|
||||
|
||||
from . import install_storage as storage
|
||||
|
||||
DEFAULT_PAYLOAD = '/run/initramfs/live/LiveOS/squashfs.img'
|
||||
LIVE_REMOVABLE = (
|
||||
'/etc/systemd/system/getty@*.service.d/90-hydra-live.conf',
|
||||
@@ -46,13 +50,18 @@ class InstallError(RuntimeError):
|
||||
|
||||
@dataclass
|
||||
class Request:
|
||||
disk: str
|
||||
disk: str | None
|
||||
user: str
|
||||
payload: str = DEFAULT_PAYLOAD
|
||||
execute: bool = False
|
||||
confirm_erase: str | None = None
|
||||
timezone: str = 'Europe/Moscow'
|
||||
kernel: str | None = None
|
||||
root_partition: str | None = None
|
||||
esp_partition: str | None = None
|
||||
confirm_format: str | None = None
|
||||
root_fs: str = 'ext4'
|
||||
register_boot_entry: bool = False
|
||||
|
||||
|
||||
class Runner:
|
||||
@@ -109,7 +118,13 @@ class Topology:
|
||||
raise InstallError('Zero-size device is not an unrelated empty optical drive')
|
||||
if not isinstance(identity, str) or not re.fullmatch(r'\d+:\d+', identity):
|
||||
raise InstallError('Invalid lsblk device identity')
|
||||
if path in result.nodes and any(result.nodes[path][key] != node[key] for key in ('type', 'size', 'maj:min', 'mountpoints')):
|
||||
for key in ('partuuid', 'parttype', 'fstype', 'uuid'):
|
||||
if node.get(key) is not None and not isinstance(node[key], str):
|
||||
raise InstallError('Malformed lsblk storage metadata')
|
||||
if node.get('start') is not None and (isinstance(node['start'], bool) or not isinstance(node['start'], int)):
|
||||
raise InstallError('Malformed lsblk partition start')
|
||||
if path in result.nodes and any(result.nodes[path].get(key) != node.get(key) for key in
|
||||
('type', 'size', 'maj:min', 'mountpoints', 'start', 'partuuid', 'parttype', 'fstype', 'uuid')):
|
||||
raise InstallError('Inconsistent duplicate lsblk node')
|
||||
result.nodes[path] = node
|
||||
result.edges.setdefault(path, set())
|
||||
@@ -224,7 +239,7 @@ class SystemProbe:
|
||||
|
||||
def snapshot(self, payload):
|
||||
raw = self.runner.run(['lsblk', '--json', '--paths', '--bytes', '--output',
|
||||
'PATH,TYPE,PKNAME,MOUNTPOINTS,SIZE,MAJ:MIN'])
|
||||
'PATH,TYPE,PKNAME,MOUNTPOINTS,SIZE,MAJ:MIN,START,PARTUUID,PARTTYPE,FSTYPE,UUID'])
|
||||
# lsblk PATH can use /dev/mapper aliases while findmnt uses /dev/dm-N.
|
||||
try:
|
||||
decoded = json.loads(raw)
|
||||
@@ -275,11 +290,15 @@ class SystemProbe:
|
||||
for path in pending:
|
||||
identity = self._loop_backing_identity(path, topo.nodes[path]['maj:min'])
|
||||
sources = [p for p, n in topo.nodes.items() if n['maj:min'] == identity]
|
||||
if len(sources) != 1:
|
||||
raise InstallError(f'Unknown physical backing for mounted loop: {path} ({identity})')
|
||||
source = sources[0]
|
||||
topo.link(path, source)
|
||||
topo.protect(source)
|
||||
if not sources:
|
||||
btrfs_mounts = [m for m in mounts if m['fstype'] == 'btrfs' and m['maj:min'] == identity]
|
||||
member_sets = [set(self._btrfs_members(m, topo)) for m in btrfs_mounts]
|
||||
if not member_sets or any(members != member_sets[0] for members in member_sets[1:]):
|
||||
raise InstallError(f'Unknown physical backing for mounted loop: {path} ({identity})')
|
||||
sources = sorted(member_sets[0])
|
||||
for source in sources:
|
||||
topo.link(path, source)
|
||||
topo.protect(source)
|
||||
inspected.add(path)
|
||||
return topo
|
||||
|
||||
@@ -331,7 +350,9 @@ class SystemProbe:
|
||||
raise InstallError('Malformed findmnt topology') from exc
|
||||
|
||||
def _block_source(self, mount, topo):
|
||||
if mount['fstype'] in {'zfs', 'btrfs'}:
|
||||
if mount['fstype'] == 'btrfs':
|
||||
return self._btrfs_source(mount, topo)
|
||||
if mount['fstype'] == 'zfs':
|
||||
raise InstallError('Cannot safely resolve all backing disks of mounted ' + mount['fstype'])
|
||||
source = mount['source'].split('[', 1)[0]
|
||||
identity = mount['maj:min']
|
||||
@@ -352,6 +373,49 @@ class SystemProbe:
|
||||
raise InstallError(f'Unknown mounted block source: {source}')
|
||||
return None
|
||||
|
||||
def _btrfs_source(self, mount, topo):
|
||||
return self._btrfs_members(mount, topo)[0]
|
||||
|
||||
def _btrfs_members(self, mount, topo):
|
||||
if not Path(mount['target']).is_absolute() or any(ord(c) < 32 for c in mount['target']):
|
||||
raise InstallError('Invalid mounted btrfs path')
|
||||
raw = self.runner.run(['btrfs', 'filesystem', 'show', '--raw', mount['target']])
|
||||
counts = re.findall(r'Total devices (\d+) FS bytes used \d+', raw)
|
||||
rows = re.findall(r'^\s*devid\s+(\d+)\s+size\s+\d+\s+used\s+\d+\s+path\s+(/dev/[^\s]+)\s*$', raw, re.MULTILINE)
|
||||
if len(counts) != 1 or int(counts[0]) <= 0 or len(rows) != int(counts[0]) or len({row[0] for row in rows}) != len(rows):
|
||||
raise InstallError('Cannot safely resolve all backing members of mounted btrfs')
|
||||
paths = [self.resolve(row[1]) for row in rows]
|
||||
if len(set(paths)) != len(paths) or any(path not in topo.nodes for path in paths):
|
||||
raise InstallError('Unknown or duplicate mounted btrfs backing member')
|
||||
source = mount['source'].split('[', 1)[0]
|
||||
if source.startswith('/dev/') and self.resolve(source) in topo.nodes and self.resolve(source) not in paths:
|
||||
raise InstallError('Mounted btrfs source is absent from backing members')
|
||||
for path in paths:
|
||||
topo.protect(path)
|
||||
return paths
|
||||
|
||||
def verify_identity(self, path, node):
|
||||
try:
|
||||
info = os.stat(path)
|
||||
identity = f'{os.major(info.st_rdev)}:{os.minor(info.st_rdev)}'
|
||||
if not stat.S_ISBLK(info.st_mode) or identity != node['maj:min']:
|
||||
raise InstallError('Block device identity changed: ' + path)
|
||||
sysfs = Path('/sys/dev/block') / identity
|
||||
if int((sysfs / 'size').read_text()) * 512 != node['size']:
|
||||
raise InstallError('Block device capacity changed: ' + path)
|
||||
if list((sysfs / 'holders').iterdir()):
|
||||
raise InstallError('Selected device has active holders: ' + path)
|
||||
if node['type'] == 'part' and int((sysfs / 'start').read_text()) != node['start']:
|
||||
raise InstallError('Partition bounds changed: ' + path)
|
||||
except (OSError, ValueError) as exc:
|
||||
raise InstallError('Cannot verify kernel block identity: ' + path) from exc
|
||||
|
||||
def partition_number(self, path, node):
|
||||
try:
|
||||
return int((Path('/sys/dev/block') / node['maj:min'] / 'partition').read_text())
|
||||
except (OSError, ValueError) as exc:
|
||||
raise InstallError('Cannot read kernel partition number: ' + path) from exc
|
||||
|
||||
def _source_for_path(self, path, topo):
|
||||
raw = self.runner.run(['findmnt', '--json', '--target', path, '--output',
|
||||
'SOURCE,TARGET,FSTYPE,MAJ:MIN,OPTIONS'])
|
||||
@@ -361,9 +425,17 @@ class SystemProbe:
|
||||
return self._block_source(mounts[0], topo)
|
||||
|
||||
|
||||
def validate_request(request, probe):
|
||||
if not isinstance(request.disk, str) or not DEVICE.fullmatch(request.disk) or '..' in PurePosixPath(request.disk).parts:
|
||||
def validate_request(request, probe, *, return_plan=False):
|
||||
existing = request.root_partition is not None or request.esp_partition is not None
|
||||
if request.root_fs not in {'ext4', 'btrfs'}:
|
||||
raise InstallError('Root filesystem must be ext4 or btrfs')
|
||||
if existing:
|
||||
if request.disk is not None or request.confirm_erase is not None:
|
||||
raise InstallError('Choose exactly one installation mode; partitions cannot use --disk/--confirm-erase')
|
||||
elif not isinstance(request.disk, str) or not DEVICE.fullmatch(request.disk) or '..' in PurePosixPath(request.disk).parts:
|
||||
raise InstallError('Choose an explicit /dev whole-disk device')
|
||||
elif request.confirm_format is not None:
|
||||
raise InstallError('--confirm-format is only valid for existing partitions')
|
||||
if not re.fullmatch(r'[a-z_][a-z0-9_-]{0,31}', request.user) or request.user in {'root', 'hydra-live', 'greetd'}:
|
||||
raise InstallError('Choose a valid nonreserved lowercase Unix username')
|
||||
if not re.fullmatch(r'[A-Za-z_+-]+(?:/[A-Za-z0-9_+-]+)+', request.timezone):
|
||||
@@ -372,6 +444,9 @@ def validate_request(request, probe):
|
||||
raise InstallError('Payload must be an absolute path without control characters')
|
||||
if request.kernel is not None and not KERNEL.fullmatch(request.kernel):
|
||||
raise InstallError('Invalid kernel version')
|
||||
if existing:
|
||||
plan = storage.partition_plan(request, probe, probe.snapshot(request.payload))
|
||||
return plan if return_plan else plan.root
|
||||
target = probe.resolve(request.disk)
|
||||
if not DEVICE.fullmatch(target) or not probe.physical_disk(target):
|
||||
raise InstallError(f'Target is not a physical whole block disk: {target}')
|
||||
@@ -385,6 +460,10 @@ def validate_request(request, probe):
|
||||
forbidden = topo.component(target) & topo.protected
|
||||
if forbidden:
|
||||
raise InstallError('Target backs mounted/root/live storage: ' + ', '.join(sorted(forbidden)))
|
||||
probe.verify_identity(target, topo.nodes[target])
|
||||
if return_plan:
|
||||
return storage.DiskPlan(target, topo.nodes[target]['size'],
|
||||
tuple(sorted((p, storage.fingerprint(topo.nodes[p])) for p in topo.component(target))))
|
||||
return target
|
||||
|
||||
|
||||
@@ -393,7 +472,7 @@ def partitions(target):
|
||||
return target + suffix + '1', target + suffix + '2'
|
||||
|
||||
|
||||
def partition_commands(target):
|
||||
def partition_commands(target, root_fs='ext4'):
|
||||
esp, root = partitions(target)
|
||||
return [
|
||||
['parted', '--script', target, 'mklabel', 'gpt'],
|
||||
@@ -402,19 +481,37 @@ def partition_commands(target):
|
||||
['parted', '--script', target, 'unit', 'MiB', 'mkpart', 'root', 'ext4', '1025MiB', '100%'],
|
||||
['partprobe', target], ['udevadm', 'settle'],
|
||||
['mkfs.vfat', '-F', '32', '-n', 'HYDRA_EFI', esp],
|
||||
['mkfs.ext4', '-F', '-L', 'HYDRA_ROOT', root],
|
||||
storage.format_root(root, root_fs),
|
||||
]
|
||||
|
||||
|
||||
def grub_command(root):
|
||||
def grub_command(root, preserve=False):
|
||||
return ['chroot', str(root), 'grub-install', '--target=x86_64-efi',
|
||||
'--efi-directory=/boot/efi', '--bootloader-id=HydraOS', '--removable', '--no-nvram']
|
||||
'--efi-directory=/boot/efi', '--bootloader-id=HydraOS',
|
||||
*([] if preserve else ['--removable']), '--no-nvram']
|
||||
|
||||
|
||||
def render_plan(request, target, root):
|
||||
esp, part = partitions(target)
|
||||
commands = partition_commands(target) + [
|
||||
['mount', part, str(root)], ['unsquashfs', '-f', '-d', str(root), request.payload],
|
||||
existing = request.root_partition is not None
|
||||
esp, part = (request.esp_partition, target) if existing else partitions(target)
|
||||
commands = [['unsquashfs', '-s', request.payload], ['unsquashfs', '-lln', request.payload]]
|
||||
if existing:
|
||||
temporary = str(Path(root).parent / 'esp-preflight-<random>')
|
||||
commands.extend([['mount', '-t', 'vfat', '-o', 'ro,nosuid,nodev,noexec', esp, temporary],
|
||||
['umount', temporary]])
|
||||
commands.extend([storage.format_root(part, request.root_fs)] if existing else partition_commands(target, request.root_fs))
|
||||
if request.root_fs == 'btrfs':
|
||||
top = str(Path(root).parent / 'btrfs-top')
|
||||
commands.append(['mount', '-t', 'btrfs', '-o', 'subvolid=5', part, top])
|
||||
commands.extend(['btrfs', 'subvolume', 'create', str(Path(top) / name)]
|
||||
for name in ('@root', '@home', '@snapshots'))
|
||||
commands.extend([['umount', top], ['mount', '-t', 'btrfs', '-o', 'subvol=@root', part, str(root)],
|
||||
['mount', '-t', 'btrfs', '-o', 'subvol=@home', part, str(Path(root) / 'home')],
|
||||
['mount', '-t', 'btrfs', '-o', 'subvol=@snapshots', part, str(Path(root) / '.snapshots')]])
|
||||
else:
|
||||
commands.append(['mount', part, str(root)])
|
||||
commands += [
|
||||
['unsquashfs', '-f', '-d', str(root), request.payload],
|
||||
['mount', esp, str(Path(root) / 'boot/efi')],
|
||||
['blkid', '-s', 'UUID', '-o', 'value', part],
|
||||
['blkid', '-s', 'UUID', '-o', 'value', esp],
|
||||
@@ -430,12 +527,18 @@ def render_plan(request, target, root):
|
||||
['chroot', str(root), 'systemd-machine-id-setup'],
|
||||
['chroot', str(root), 'dracut', '--force', '--no-hostonly', '--omit', 'dmsquash-live',
|
||||
'/boot/initramfs-<kernel>.img', '<kernel>'],
|
||||
grub_command(root), ['chroot', str(root), 'grub-mkconfig', '-o', '/boot/grub/grub.cfg'],
|
||||
grub_command(root, preserve=True),
|
||||
*([] if existing else [grub_command(root)]),
|
||||
['chroot', str(root), 'grub-mkconfig', '-o', '/boot/grub/grub.cfg'],
|
||||
]
|
||||
lines = [f'Hydra OS {"EXECUTION" if request.execute else "dry-run"}: erase all data on {target}.',
|
||||
'GPT: exactly 1 GiB FAT32 ESP, remaining space ext4 root.',
|
||||
f'GPT: exactly 1 GiB FAT32 ESP, remaining space {request.root_fs} root.',
|
||||
f'Prepared payload: {request.payload}; account: {request.user}; timezone: {request.timezone}.',
|
||||
'Commands (UUIDs/kernel discovered after unpacking; no commands run in dry-run):']
|
||||
if existing:
|
||||
lines[:2] = [f'Hydra OS {"EXECUTION" if request.execute else "dry-run"}: format ONLY {target} as {request.root_fs}.',
|
||||
f'Preserve GPT, existing ESP {esp}, foreign EFI loaders, fallback loader and BootOrder.',
|
||||
'First inspect the ESP read-only for collision/free space and hash foreign files.']
|
||||
lines.extend(' ' + shlex.join(c) for c in commands)
|
||||
lines.extend(['Write UUID fstab, en_US.UTF-8 and ru_RU.UTF-8, timezone, installed dracut/GRUB defaults.',
|
||||
'Remove allowlisted live-mode files, hydra-live account and /home/hydra-live; reset machine-id.',
|
||||
@@ -443,6 +546,11 @@ def render_plan(request, target, root):
|
||||
'chpasswd receives the TTY-entered password on private stdin; root remains locked.',
|
||||
'Unmount tracked sys/proc/dev/ESP/root in reverse order on success or failure.',
|
||||
f'To execute, add --execute --confirm-erase {target} (as root; no automatic sudo).'])
|
||||
if existing:
|
||||
lines[-1] = f'To execute, add --execute --confirm-format {target} (as root; no automatic sudo).'
|
||||
lines.append('Default: select EFI/HydraOS/grubx64.efi in firmware manually; no firmware changes.')
|
||||
if request.root_fs == 'btrfs':
|
||||
lines.append('Btrfs: @root contains /boot and Portage; @home and @snapshots are separate subvolumes.')
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
@@ -572,7 +680,10 @@ def install(request, runner, probe, root, password, log: TextIO):
|
||||
raise InstallError('Installation requires --execute')
|
||||
if not isinstance(password, str) or not password or any(c in password for c in '\r\n\x00'):
|
||||
raise InstallError('Password must be nonempty and contain no newline or NUL')
|
||||
target = validate_request(request, probe)
|
||||
validated = validate_request(request, probe, return_plan=True)
|
||||
existing = isinstance(validated, storage.PartitionPlan)
|
||||
target = validated.root if existing else validated.target
|
||||
esp, part = (validated.esp, validated.root) if existing else partitions(target)
|
||||
root = Path(root)
|
||||
if not root.is_absolute() or root.is_symlink() or not root.is_dir() or any(root.iterdir()):
|
||||
raise InstallError('Target mount directory must be an empty absolute directory')
|
||||
@@ -594,13 +705,46 @@ def install(request, runner, probe, root, password, log: TextIO):
|
||||
|
||||
try:
|
||||
run(['unsquashfs', '-s', request.payload])
|
||||
# Recheck after preflight, directly before the first destructive command.
|
||||
if validate_request(request, probe) != target:
|
||||
raise InstallError('Target device changed during validation')
|
||||
for argv in partition_commands(target):
|
||||
required = storage.payload_capacity(run(['unsquashfs', '-lln', request.payload]))
|
||||
capacity = validated.root_size if existing else validated.size - 1025 * 1024**2
|
||||
if capacity < required:
|
||||
raise InstallError('Root has insufficient capacity for the uncompressed payload and install reserve')
|
||||
foreign = None
|
||||
if existing:
|
||||
# An independent temporary mount keeps the target root empty.
|
||||
temporary = tempfile.mkdtemp(prefix='esp-preflight-', dir=root.parent)
|
||||
mount(['mount', '-t', 'vfat', '-o', 'ro,nosuid,nodev,noexec', esp, temporary], temporary)
|
||||
try:
|
||||
foreign = storage.inspect_esp(temporary)
|
||||
finally:
|
||||
# Never recurse through a directory that could still be mounted.
|
||||
run(['umount', temporary])
|
||||
mounted.pop()
|
||||
try:
|
||||
Path(temporary).rmdir()
|
||||
except OSError:
|
||||
pass # Retain unexpected contents; never delete ESP files.
|
||||
# Recheck the canonical paths, kernel identities, GPT IDs and bounds
|
||||
# after read-only ESP inspection, immediately before formatting.
|
||||
if validate_request(request, probe, return_plan=True) != validated:
|
||||
raise InstallError('Target device identity or partition bounds changed during validation')
|
||||
for argv in ([storage.format_root(part, request.root_fs)] if existing else partition_commands(target, request.root_fs)):
|
||||
run(argv)
|
||||
esp, part = partitions(target)
|
||||
mount(['mount', part, str(root)], root)
|
||||
if request.root_fs == 'btrfs':
|
||||
top = root.parent / 'btrfs-top'
|
||||
top.mkdir()
|
||||
mount(['mount', '-t', 'btrfs', '-o', 'subvolid=5', part, str(top)], top)
|
||||
for subvolume in ('@root', '@home', '@snapshots'):
|
||||
run(['btrfs', 'subvolume', 'create', str(top / subvolume)])
|
||||
run(['umount', str(top)])
|
||||
mounted.pop()
|
||||
top.rmdir()
|
||||
mount(['mount', '-t', 'btrfs', '-o', 'subvol=@root', part, str(root)], root)
|
||||
for directory, subvolume in (('home', '@home'), ('.snapshots', '@snapshots')):
|
||||
(root / directory).mkdir()
|
||||
mount(['mount', '-t', 'btrfs', '-o', 'subvol=' + subvolume, part, str(root / directory)], root / directory)
|
||||
else:
|
||||
mount(['mount', part, str(root)], root)
|
||||
run(['unsquashfs', '-f', '-d', str(root), request.payload])
|
||||
kernel = select_kernel(root, request.kernel)
|
||||
remove_live_files(root)
|
||||
@@ -614,7 +758,30 @@ def install(request, runner, probe, root, password, log: TextIO):
|
||||
esp_uuid = run(['blkid', '-s', 'UUID', '-o', 'value', esp]).strip()
|
||||
if any(not re.fullmatch(r'[A-Fa-f0-9-]+', value) for value in (root_uuid, esp_uuid)):
|
||||
raise InstallError('Missing or invalid partition UUID')
|
||||
write_config(root, 'etc/fstab', f'UUID={root_uuid} / ext4 defaults 0 1\nUUID={esp_uuid} /boot/efi vfat umask=0077 0 2\n')
|
||||
if request.root_fs == 'btrfs':
|
||||
root_lines = (f'UUID={root_uuid} / btrfs defaults,subvol=@root 0 0\n'
|
||||
f'UUID={root_uuid} /home btrfs defaults,subvol=@home 0 0\n'
|
||||
f'UUID={root_uuid} /.snapshots btrfs defaults,subvol=@snapshots 0 0\n')
|
||||
else:
|
||||
root_lines = f'UUID={root_uuid} / ext4 defaults 0 1\n'
|
||||
write_config(root, 'etc/fstab', root_lines + f'UUID={esp_uuid} /boot/efi vfat umask=0077 0 2\n')
|
||||
root_partuuid = run(['blkid', '-s', 'PARTUUID', '-o', 'value', part]).strip()
|
||||
esp_partuuid = run(['blkid', '-s', 'PARTUUID', '-o', 'value', esp]).strip()
|
||||
if not all(storage.PARTUUID.fullmatch(value) for value in (root_partuuid, esp_partuuid)):
|
||||
raise InstallError('Missing or invalid GPT partition UUID for installed storage marker')
|
||||
if existing:
|
||||
before_root, before_esp = validated.root_partuuid, validated.esp_partuuid
|
||||
if (root_partuuid.lower(), esp_partuuid.lower()) != (before_root.lower(), before_esp.lower()):
|
||||
raise InstallError('GPT partition identity changed after formatting')
|
||||
if esp_uuid.lower() != validated.esp_uuid.lower():
|
||||
raise InstallError('ESP filesystem identity changed after preflight')
|
||||
marker = dict(format=1, install_uuid=str(uuid.uuid4()), filesystem=request.root_fs,
|
||||
root_fs_uuid=root_uuid, root_partuuid=root_partuuid,
|
||||
esp_uuid=esp_uuid, esp_partuuid=esp_partuuid,
|
||||
owned_efi_path='EFI/HydraOS')
|
||||
if request.root_fs == 'btrfs':
|
||||
marker['root_subvolume'] = '@root'
|
||||
write_config(root, 'etc/hydra/storage.json', json.dumps(marker, sort_keys=True) + '\n', 0o600)
|
||||
write_config(root, 'etc/locale.gen', 'en_US.UTF-8 UTF-8\nru_RU.UTF-8 UTF-8\n')
|
||||
write_config(root, 'etc/locale.conf', 'LANG=en_US.UTF-8\n')
|
||||
write_config(root, 'etc/timezone', request.timezone + '\n')
|
||||
@@ -629,9 +796,13 @@ def install(request, runner, probe, root, password, log: TextIO):
|
||||
write_config(root, 'etc/machine-id', '')
|
||||
dbus_machine_id = target_path(root, 'var/lib/dbus') / 'machine-id'
|
||||
dbus_machine_id.unlink(missing_ok=True)
|
||||
boot_args = f'root=UUID={root_uuid}' + (' rootflags=subvol=@root' if request.root_fs == 'btrfs' else '')
|
||||
write_config(root, 'etc/dracut.conf.d/90-hydra-installed.conf',
|
||||
'hostonly="no"\nhostonly_cmdline="no"\nomit_dracutmodules+=" dmsquash-live "\n# No root=live: or rd.live arguments on the installed disk.\n')
|
||||
write_config(root, 'etc/default/grub', 'GRUB_DISTRIBUTOR="Hydra OS"\nGRUB_CMDLINE_LINUX=""\nGRUB_TIMEOUT=3\n')
|
||||
'hostonly="no"\nhostonly_cmdline="no"\nomit_dracutmodules+=" dmsquash-live "\n'
|
||||
+ ('filesystems+=" btrfs "\n' if request.root_fs == 'btrfs' else '')
|
||||
+ f'kernel_cmdline="{boot_args}"\n')
|
||||
write_config(root, 'etc/default/grub', 'GRUB_DISTRIBUTOR="Hydra OS"\n'
|
||||
+ f'GRUB_CMDLINE_LINUX="{boot_args}"\nGRUB_TIMEOUT=3\nGRUB_DISABLE_OS_PROBER=true\n')
|
||||
write_config(root, 'etc/sudoers.d/90-hydra-admin', '%wheel ALL=(ALL:ALL) ALL\n', 0o440)
|
||||
mount(['mount', '--rbind', '/dev', str(root / 'dev')], root / 'dev', True)
|
||||
run(['mount', '--make-rslave', str(root / 'dev')])
|
||||
@@ -650,8 +821,16 @@ def install(request, runner, probe, root, password, log: TextIO):
|
||||
chroot('systemd-machine-id-setup')
|
||||
chroot('dracut', '--force', '--no-hostonly', '--omit', 'dmsquash-live',
|
||||
'/boot/initramfs-' + kernel + '.img', kernel)
|
||||
run(grub_command(root))
|
||||
run(grub_command(root, preserve=True))
|
||||
if not existing:
|
||||
run(grub_command(root))
|
||||
chroot('grub-mkconfig', '-o', '/boot/grub/grub.cfg')
|
||||
if existing and storage.foreign_efi_hashes(root / 'boot/efi', ignore_owned=True) != foreign:
|
||||
raise InstallError('Foreign ESP files changed; refusing successful installation')
|
||||
if request.register_boot_entry:
|
||||
parent, number = (validated.esp_parent, validated.esp_number) if existing else (target, 1)
|
||||
run(['efibootmgr', '--create-only', '--disk', parent, '--part', str(number),
|
||||
'--label', 'HydraOS', '--loader', r'\EFI\HydraOS\grubx64.efi'])
|
||||
run(['sync'])
|
||||
except (InstallError, OSError, KeyboardInterrupt) as exc:
|
||||
error = exc
|
||||
@@ -674,6 +853,8 @@ def install(request, runner, probe, root, password, log: TextIO):
|
||||
error = InstallError('Mount cleanup failed: ' + '; '.join(cleanup_errors))
|
||||
if error:
|
||||
raise InstallError(str(error)) from error
|
||||
if existing and not request.register_boot_entry:
|
||||
print('Select EFI/HydraOS/grubx64.efi manually in firmware; BootOrder was not changed.', file=log, flush=True)
|
||||
print('Installation finished; remove the live ISO before booting the installed disk.', file=log, flush=True)
|
||||
|
||||
|
||||
@@ -686,8 +867,13 @@ def preflight(request, runner):
|
||||
raise InstallError('Execution requires a TTY for password entry')
|
||||
if not Path('/sys/firmware/efi').is_dir():
|
||||
raise InstallError('Boot the live medium in UEFI mode before installation')
|
||||
for command in ('lsblk', 'findmnt', 'parted', 'partprobe', 'udevadm', 'mkfs.vfat',
|
||||
'mkfs.ext4', 'mount', 'umount', 'unsquashfs', 'blkid', 'chroot', 'sync'):
|
||||
commands = ['lsblk', 'findmnt', 'mount', 'umount', 'unsquashfs', 'blkid', 'chroot', 'sync']
|
||||
if request.root_partition is None:
|
||||
commands.extend(['parted', 'partprobe', 'udevadm', 'mkfs.vfat'])
|
||||
commands.extend(['btrfs', 'mkfs.btrfs'] if request.root_fs == 'btrfs' else ['mkfs.ext4'])
|
||||
if request.register_boot_entry:
|
||||
commands.append('efibootmgr')
|
||||
for command in commands:
|
||||
if shutil.which(command) is None:
|
||||
raise InstallError('Required live installer tool missing: ' + command)
|
||||
runner.run(['unsquashfs', '-s', request.payload])
|
||||
@@ -711,8 +897,13 @@ def prompt_password():
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description='Hydra OS explicit whole-disk installer; dry-run by default')
|
||||
parser = argparse.ArgumentParser(description='Hydra OS explicit disk or existing-partition installer; dry-run by default')
|
||||
parser.add_argument('--disk', help='explicit physical whole-disk target; never automatically selected')
|
||||
parser.add_argument('--root-partition', help='existing root partition; formats only this partition')
|
||||
parser.add_argument('--esp-partition', help='existing FAT GPT ESP; never formatted in partition mode')
|
||||
parser.add_argument('--confirm-format', help='must exactly match the resolved root partition with --execute')
|
||||
parser.add_argument('--root-fs', choices=('ext4', 'btrfs'), default='ext4')
|
||||
parser.add_argument('--register-boot-entry', action='store_true', help='opt in to create-only EFI entry, preserving BootOrder')
|
||||
parser.add_argument('--user', help='installed administrator username')
|
||||
parser.add_argument('--payload', default=DEFAULT_PAYLOAD, help='prepared live squashfs root payload')
|
||||
parser.add_argument('--execute', action='store_true')
|
||||
@@ -722,8 +913,8 @@ def main(argv=None):
|
||||
args = parser.parse_args(argv)
|
||||
runner = Runner()
|
||||
try:
|
||||
if not args.disk:
|
||||
if args.execute or args.confirm_erase:
|
||||
if not args.disk and not args.root_partition and not args.esp_partition:
|
||||
if args.execute or args.confirm_erase or args.confirm_format or args.register_boot_entry:
|
||||
raise InstallError('--execute requires an explicit --disk and --user')
|
||||
topo = Topology.from_json(runner.run(['lsblk', '--json', '--paths', '--bytes', '--output',
|
||||
'PATH,TYPE,PKNAME,MOUNTPOINTS,SIZE,MAJ:MIN']))
|
||||
|
||||
Reference in New Issue
Block a user