957 lines
49 KiB
Python
957 lines
49 KiB
Python
"""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
|
|
LIVE_REMOVABLE. Store all other live-only configuration in live-mode itself;
|
|
ordinary installed configuration must already be present in the payload.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
from dataclasses import dataclass
|
|
import getpass
|
|
import fcntl
|
|
import json
|
|
import os
|
|
from pathlib import Path, PurePosixPath
|
|
import re
|
|
import shlex
|
|
import shutil
|
|
import stat
|
|
import struct
|
|
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',
|
|
'/etc/systemd/system/serial-getty@*.service.d/90-hydra-live.conf',
|
|
'/etc/systemd/system/greetd.service.d/90-hydra-live.conf',
|
|
'/etc/sudoers.d/90-hydra-live',
|
|
'/etc/greetd/live.toml',
|
|
'/etc/NetworkManager/system-connections/90-hydra-live.nmconnection',
|
|
)
|
|
DEVICE = re.compile(r'/dev/[A-Za-z0-9_.+:/-]+\Z')
|
|
KERNEL = re.compile(r'[A-Za-z0-9][A-Za-z0-9_.+-]*\Z')
|
|
KNOWN_TYPES = {'disk', 'part', 'loop', 'rom', 'crypt', 'lvm', 'mpath', 'dm',
|
|
'raid0', 'raid1', 'raid4', 'raid5', 'raid6', 'raid10', 'md', 'zram'}
|
|
|
|
|
|
class InstallError(RuntimeError):
|
|
"""A failed safety check or operation; never suppress this in execution."""
|
|
|
|
|
|
@dataclass
|
|
class Request:
|
|
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:
|
|
"""Checked argv-only commands. Secret-bearing commands redact all output."""
|
|
def run(self, argv, *, input=None, sensitive=False):
|
|
try:
|
|
result = subprocess.run(list(argv), input=input, text=True,
|
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
shell=False, check=False)
|
|
except OSError as exc:
|
|
raise InstallError(f'Cannot run {argv[0]}: {exc.strerror}') from exc
|
|
if result.returncode:
|
|
detail = '' if sensitive else ': ' + result.stderr.strip()[:2000]
|
|
raise InstallError(f'Command failed ({result.returncode}): {shlex.join(argv)}{detail}')
|
|
return '' if sensitive else result.stdout
|
|
|
|
|
|
class Topology:
|
|
def __init__(self):
|
|
self.nodes = {}
|
|
self.edges = {}
|
|
self.parents = {}
|
|
self.protected = set()
|
|
|
|
@classmethod
|
|
def from_json(cls, raw):
|
|
try:
|
|
data = json.loads(raw)
|
|
except (ValueError, TypeError) as exc:
|
|
raise InstallError('Malformed lsblk JSON') from exc
|
|
if not isinstance(data, dict) or not isinstance(data.get('blockdevices'), list) or not data['blockdevices']:
|
|
raise InstallError('lsblk must return a nonempty blockdevices array')
|
|
result = cls()
|
|
parents = []
|
|
|
|
def visit(node, ancestor=None, trail=()):
|
|
if not isinstance(node, dict):
|
|
raise InstallError('Malformed lsblk node')
|
|
path, kind = node.get('path'), node.get('type')
|
|
if not isinstance(path, str) or not DEVICE.fullmatch(path) or '..' in PurePosixPath(path).parts:
|
|
raise InstallError('Invalid lsblk device path')
|
|
if path in trail or kind not in KNOWN_TYPES:
|
|
raise InstallError('Cyclic or unknown lsblk topology')
|
|
mounts = node.get('mountpoints')
|
|
size, identity = node.get('size'), node.get('maj:min')
|
|
if not isinstance(mounts, list) or any(m is not None and not isinstance(m, str) for m in mounts):
|
|
raise InstallError('Malformed lsblk mountpoints')
|
|
if isinstance(size, bool) or not isinstance(size, int) or size < 0:
|
|
raise InstallError('Invalid lsblk size')
|
|
empty_optical = (kind == 'rom' and not any(mounts)
|
|
and not node.get('children') and node.get('pkname') is None
|
|
and ancestor is None)
|
|
if size == 0 and not empty_optical:
|
|
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')
|
|
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())
|
|
result.parents.setdefault(path, set())
|
|
if any(m for m in mounts):
|
|
result.protected.add(path)
|
|
pkname = node.get('pkname')
|
|
if pkname is not None:
|
|
if not isinstance(pkname, str) or not pkname:
|
|
raise InstallError('Invalid lsblk parent')
|
|
parent = pkname if pkname.startswith('/dev/') else '/dev/' + pkname
|
|
if not DEVICE.fullmatch(parent):
|
|
raise InstallError('Invalid lsblk parent path')
|
|
parents.append((path, parent))
|
|
if ancestor:
|
|
parents.append((path, ancestor))
|
|
children = node.get('children', [])
|
|
if not isinstance(children, list):
|
|
raise InstallError('Malformed lsblk children')
|
|
for child in children:
|
|
visit(child, path, trail + (path,))
|
|
|
|
for node in data['blockdevices']:
|
|
visit(node)
|
|
identities = {}
|
|
for path, node in result.nodes.items():
|
|
identity = node['maj:min']
|
|
if identity in identities and identities[identity] != path:
|
|
raise InstallError('Conflicting block-device identities')
|
|
identities[identity] = path
|
|
directed = {path: set() for path in result.nodes}
|
|
for child, parent in parents:
|
|
result.link(child, parent)
|
|
directed[child].add(parent)
|
|
visiting, visited = set(), set()
|
|
def check_cycle(path):
|
|
if path in visiting:
|
|
raise InstallError('Cyclic block-device parent references')
|
|
if path in visited:
|
|
return
|
|
visiting.add(path)
|
|
for parent in directed[path]:
|
|
check_cycle(parent)
|
|
visiting.remove(path)
|
|
visited.add(path)
|
|
for path in directed:
|
|
check_cycle(path)
|
|
for path, node in result.nodes.items():
|
|
if node['size'] == 0 and result.edges[path]:
|
|
raise InstallError('Empty optical drive has block-device dependencies')
|
|
# An undirected child edge does not establish backing ancestry.
|
|
# Each layer must identify its own parent, even when another
|
|
# apparently unrelated disk is the requested installation target.
|
|
if node['type'] not in {'disk', 'rom', 'loop', 'zram'} and not directed[path]:
|
|
raise InstallError(f"Device has no known backing parent: {path} ({node['type']})")
|
|
return result
|
|
|
|
def link(self, child, parent):
|
|
if child == parent or child not in self.nodes or parent not in self.nodes:
|
|
raise InstallError('Unresolved or cyclic block-device parent')
|
|
# Backing links discovered after lsblk must obey the same directed
|
|
# ancestry rule. An undirected shared disk is not itself a cycle.
|
|
seen, pending = set(), [parent]
|
|
while pending:
|
|
current = pending.pop()
|
|
if current == child:
|
|
raise InstallError('Cyclic block-device backing ancestry')
|
|
if current not in seen:
|
|
seen.add(current)
|
|
pending.extend(self.parents[current] - seen)
|
|
self.parents[child].add(parent)
|
|
self.edges[child].add(parent)
|
|
self.edges[parent].add(child)
|
|
|
|
def protect(self, path):
|
|
if path not in self.nodes:
|
|
raise InstallError(f'Cannot resolve mounted/live source: {path}')
|
|
if self.nodes[path]['size'] == 0:
|
|
raise InstallError(f'Empty optical drive unexpectedly backs mounted/live storage: {path}')
|
|
self.protected.add(path)
|
|
|
|
def component(self, path):
|
|
seen, pending = set(), [path]
|
|
while pending:
|
|
current = pending.pop()
|
|
if current not in seen:
|
|
seen.add(current)
|
|
pending.extend(self.edges[current] - seen)
|
|
return seen
|
|
|
|
|
|
class SystemProbe:
|
|
"""Read-only Linux topology probes, including loop backing files."""
|
|
def __init__(self, runner=None):
|
|
self.runner = runner or Runner()
|
|
|
|
def resolve(self, path):
|
|
return os.path.realpath(path)
|
|
|
|
def physical_disk(self, path):
|
|
try:
|
|
info = os.stat(path)
|
|
if not stat.S_ISBLK(info.st_mode):
|
|
return False
|
|
device = Path(f'/sys/dev/block/{os.major(info.st_rdev)}:{os.minor(info.st_rdev)}')
|
|
if not device.exists() or (device / 'partition').exists():
|
|
return False
|
|
# Reject loop, device mapper, md, ram and other virtual targets.
|
|
return '/virtual/' not in str(device.resolve()) and (device / 'device').exists()
|
|
except OSError:
|
|
return False
|
|
|
|
def snapshot(self, payload):
|
|
raw = self.runner.run(['lsblk', '--json', '--paths', '--bytes', '--output',
|
|
'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)
|
|
def canonicalize(node):
|
|
if not isinstance(node, dict):
|
|
raise ValueError()
|
|
if not isinstance(node.get('path'), str) or not DEVICE.fullmatch(node['path']) or '..' in PurePosixPath(node['path']).parts:
|
|
raise ValueError()
|
|
node['path'] = self.resolve(node['path'])
|
|
if isinstance(node.get('pkname'), str):
|
|
parent = node['pkname']
|
|
node['pkname'] = self.resolve(parent if parent.startswith('/dev/') else '/dev/' + parent)
|
|
children = node.get('children', [])
|
|
if not isinstance(children, list):
|
|
raise ValueError()
|
|
for child in children:
|
|
canonicalize(child)
|
|
for node in decoded['blockdevices']:
|
|
canonicalize(node)
|
|
topo = Topology.from_json(json.dumps(decoded))
|
|
except (ValueError, TypeError, KeyError) as exc:
|
|
raise InstallError('Malformed lsblk JSON') from exc
|
|
mounts = self._mounts(self.runner.run(['findmnt', '--json', '--output',
|
|
'SOURCE,TARGET,FSTYPE,MAJ:MIN,OPTIONS']))
|
|
for mount in mounts:
|
|
source = self._block_source(mount, topo)
|
|
if source:
|
|
topo.protect(source)
|
|
if not Path(payload).is_file():
|
|
raise InstallError(f'Prepared squashfs payload is not a regular file: {payload}')
|
|
source = self._source_for_path(self.resolve(payload), topo)
|
|
if not source:
|
|
# tmpfs/overlay payload location cannot establish live-medium ancestry.
|
|
raise InstallError('Cannot identify the payload backing block device; use --payload on the mounted live medium')
|
|
topo.protect(source)
|
|
# Loops can hide further loops, including ancestors of a mounted
|
|
# partition or device mapper. Resolve protected components to a fixed
|
|
# point, independent of lsblk ordering and payload discovery order.
|
|
inspected = set()
|
|
while True:
|
|
protected_devices = set()
|
|
for path in topo.protected:
|
|
protected_devices.update(topo.component(path))
|
|
pending = [path for path in topo.nodes if path in protected_devices
|
|
and topo.nodes[path]['type'] == 'loop' and path not in inspected]
|
|
if not pending:
|
|
break
|
|
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 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
|
|
|
|
@staticmethod
|
|
def _loop_backing_identity(path, expected_identity):
|
|
# LOOP_GET_STATUS64 reports the backing inode's filesystem device,
|
|
# independent of names which dracut switch_root may have relocated.
|
|
# lo_rdevice is nonzero when the backing inode is itself a block device.
|
|
try:
|
|
descriptor = os.open(path, os.O_RDONLY | os.O_CLOEXEC)
|
|
try:
|
|
info = os.fstat(descriptor)
|
|
identity = f'{os.major(info.st_rdev)}:{os.minor(info.st_rdev)}'
|
|
if not stat.S_ISBLK(info.st_mode) or identity != expected_identity:
|
|
raise InstallError(f'Mounted loop device identity changed: {path}')
|
|
status = fcntl.ioctl(descriptor, 0x4C05, bytes(232))
|
|
if not isinstance(status, bytes) or len(status) != 232:
|
|
raise InstallError(f'Malformed loop status: {path}')
|
|
filesystem_device, _, block_device = struct.unpack_from('=QQQ', status)
|
|
finally:
|
|
os.close(descriptor)
|
|
except (OSError, struct.error) as exc:
|
|
raise InstallError(f'Cannot inspect mounted loop backing identity: {path}') from exc
|
|
device = block_device or filesystem_device
|
|
return f'{os.major(device)}:{os.minor(device)}'
|
|
|
|
@staticmethod
|
|
def _mounts(raw):
|
|
try:
|
|
data = json.loads(raw)
|
|
roots = data['filesystems']
|
|
if not isinstance(roots, list) or not roots:
|
|
raise ValueError()
|
|
result = []
|
|
|
|
def visit(item):
|
|
if not isinstance(item, dict) or any(not isinstance(item.get(k), str) for k in ('source', 'target', 'fstype', 'maj:min')):
|
|
raise ValueError()
|
|
result.append(item)
|
|
children = item.get('children', [])
|
|
if not isinstance(children, list):
|
|
raise ValueError()
|
|
for child in children:
|
|
visit(child)
|
|
for root in roots:
|
|
visit(root)
|
|
return result
|
|
except (ValueError, TypeError, KeyError) as exc:
|
|
raise InstallError('Malformed findmnt topology') from exc
|
|
|
|
def _block_source(self, mount, topo):
|
|
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']
|
|
matches = [p for p, n in topo.nodes.items() if n['maj:min'] == identity]
|
|
if source.startswith('/dev/'):
|
|
path = self.resolve(source)
|
|
if path in topo.nodes:
|
|
if topo.nodes[path]['maj:min'] != identity:
|
|
raise InstallError('Inconsistent mounted block identity')
|
|
topo.protect(path)
|
|
return path
|
|
if not matches:
|
|
raise InstallError(f'Cannot resolve mounted/live source: {source}')
|
|
if matches:
|
|
topo.protect(matches[0])
|
|
return matches[0]
|
|
if identity.split(':', 1)[0] != '0' and mount['fstype'] not in {'zfs', 'nfs', 'nfs4', 'cifs', '9p', 'virtiofs'}:
|
|
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'])
|
|
mounts = self._mounts(raw)
|
|
if len(mounts) != 1:
|
|
raise InstallError('Ambiguous backing filesystem')
|
|
return self._block_source(mounts[0], topo)
|
|
|
|
|
|
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):
|
|
raise InstallError('Invalid timezone; use an IANA name such as Europe/Moscow')
|
|
if not Path(request.payload).is_absolute() or any(ord(c) < 32 for c in request.payload):
|
|
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}')
|
|
if request.execute and request.confirm_erase != target:
|
|
raise InstallError(f'Execution requires --confirm-erase {target} exactly')
|
|
topo = probe.snapshot(request.payload)
|
|
if target not in topo.nodes or topo.nodes[target]['type'] != 'disk':
|
|
raise InstallError('Target is not a known whole disk')
|
|
if topo.nodes[target]['size'] < 4 * 1024 ** 3:
|
|
raise InstallError('Target disk is smaller than the minimum 4 GiB')
|
|
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
|
|
|
|
|
|
def partitions(target):
|
|
suffix = 'p' if target[-1].isdigit() else ''
|
|
return target + suffix + '1', target + suffix + '2'
|
|
|
|
|
|
def partition_commands(target, root_fs='ext4'):
|
|
esp, root = partitions(target)
|
|
return [
|
|
['parted', '--script', target, 'mklabel', 'gpt'],
|
|
['parted', '--script', target, 'unit', 'MiB', 'mkpart', 'ESP', 'fat32', '1MiB', '1025MiB'],
|
|
['parted', '--script', target, 'set', '1', 'esp', 'on'],
|
|
['parted', '--script', target, 'unit', 'MiB', 'mkpart', 'root', 'ext4', '1025MiB', '100%'],
|
|
['partprobe', target], ['udevadm', 'settle'],
|
|
['mkfs.vfat', '-F', '32', '-n', 'HYDRA_EFI', esp],
|
|
storage.format_root(root, root_fs),
|
|
]
|
|
|
|
|
|
def grub_command(root, preserve=False):
|
|
return ['chroot', str(root), 'grub-install', '--target=x86_64-efi',
|
|
'--efi-directory=/boot/efi', '--bootloader-id=HydraOS',
|
|
*([] if preserve else ['--removable']), '--no-nvram']
|
|
|
|
|
|
def render_plan(request, target, root):
|
|
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],
|
|
['mount', '--rbind', '/dev', str(Path(root) / 'dev')],
|
|
['mount', '--make-rslave', str(Path(root) / 'dev')],
|
|
['mount', '-t', 'proc', 'proc', str(Path(root) / 'proc')],
|
|
['mount', '--rbind', '/sys', str(Path(root) / 'sys')],
|
|
['mount', '--make-rslave', str(Path(root) / 'sys')],
|
|
['chroot', str(root), 'locale-gen'],
|
|
['chroot', str(root), 'useradd', '--create-home', '--groups', 'wheel,kvm,video,render,audio', '--shell', '/bin/bash', request.user],
|
|
['chroot', str(root), 'chpasswd'],
|
|
['chroot', str(root), 'passwd', '--lock', '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, 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}.',
|
|
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.',
|
|
'Ensure wheel,kvm,video,render,audio groups exist before creating the installed account.',
|
|
'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)
|
|
|
|
|
|
def target_path(root, relative, *, symlinks=False):
|
|
"""Resolve a path inside a target, interpreting absolute symlinks as chroot paths."""
|
|
root = Path(root).resolve()
|
|
parts = list(PurePosixPath(relative).parts)
|
|
if parts and parts[0] == '/':
|
|
parts.pop(0)
|
|
resolved, hops = [], 0
|
|
while parts:
|
|
part = parts.pop(0)
|
|
if part in ('', '.'):
|
|
continue
|
|
if part == '..':
|
|
if not resolved:
|
|
raise InstallError('Target path escapes root')
|
|
resolved.pop()
|
|
continue
|
|
candidate = root.joinpath(*resolved, part)
|
|
if candidate.is_symlink():
|
|
if not symlinks:
|
|
raise InstallError(f'Refusing symlink traversal in target: {relative}')
|
|
hops += 1
|
|
if hops > 40:
|
|
raise InstallError('Target symlink cycle')
|
|
link = PurePosixPath(os.readlink(candidate))
|
|
extra = list(link.parts)
|
|
if link.is_absolute():
|
|
resolved = []
|
|
extra.pop(0)
|
|
parts = extra + parts
|
|
else:
|
|
resolved.append(part)
|
|
return root.joinpath(*resolved)
|
|
|
|
|
|
def remove_live_files(root):
|
|
directory = target_path(root, 'etc/hydra/live-mode')
|
|
if not directory.is_dir():
|
|
raise InstallError('Payload missing /etc/hydra/live-mode contract')
|
|
manifest = target_path(root, 'etc/hydra/live-mode/remove-on-install.json')
|
|
marker = target_path(root, 'etc/hydra/live-mode/active')
|
|
if not manifest.is_file() or not marker.is_file():
|
|
raise InstallError('Payload live-mode marker/manifest missing')
|
|
try:
|
|
paths = json.loads(manifest.read_text())
|
|
except (OSError, ValueError) as exc:
|
|
raise InstallError('Invalid live-mode removal manifest') from exc
|
|
if not isinstance(paths, list):
|
|
raise InstallError('Live removal manifest must be a JSON array')
|
|
removals = []
|
|
for path in paths:
|
|
if not isinstance(path, str) or not path.startswith('/') or '..' in PurePosixPath(path).parts or str(PurePosixPath(path)) != path:
|
|
raise InstallError('Invalid path in live removal manifest')
|
|
if not any(re.fullmatch(re.escape(pattern).replace(r'\*', r'[A-Za-z0-9_.-]+'), path) for pattern in LIVE_REMOVABLE):
|
|
raise InstallError(f'Live manifest path is not allowlisted: {path}')
|
|
leaf = PurePosixPath(path)
|
|
parent = target_path(root, str(leaf.parent))
|
|
candidate = parent / leaf.name
|
|
if candidate.exists() and not candidate.is_file() and not candidate.is_symlink():
|
|
raise InstallError('Live manifest may only remove files/symlinks')
|
|
removals.append(candidate)
|
|
for path in removals:
|
|
path.unlink(missing_ok=True)
|
|
# shutil.rmtree never follows directory symlinks inside this directory.
|
|
shutil.rmtree(directory)
|
|
|
|
|
|
def remove_live_account(root, run):
|
|
"""Remove the payload's fixed local live identity, before binding live /proc."""
|
|
passwd = target_path(root, 'etc/passwd')
|
|
try:
|
|
rows = [row.split(':') for row in passwd.read_text().splitlines() if row.split(':', 1)[0] == 'hydra-live']
|
|
except OSError as exc:
|
|
raise InstallError('Payload missing readable /etc/passwd') from exc
|
|
if len(rows) > 1:
|
|
raise InstallError('Duplicate live account in payload')
|
|
if rows:
|
|
row = rows[0]
|
|
if len(row) != 7 or not row[2].isdigit() or int(row[2]) < 1000 or row[5] != '/home/hydra-live':
|
|
raise InstallError('Live account must have a nonroot UID and exactly /home/hydra-live')
|
|
home = target_path(root, 'home/hydra-live')
|
|
if home.exists() and not home.is_dir():
|
|
raise InstallError('Live home must be an ordinary directory')
|
|
if rows:
|
|
# /proc is not mounted yet, so the live session's identical UID cannot
|
|
# make userdel mistake a running live process for a target process.
|
|
run(['chroot', str(root), 'userdel', 'hydra-live'])
|
|
if home.exists():
|
|
shutil.rmtree(home)
|
|
for spool in ('var/mail/hydra-live', 'var/spool/mail/hydra-live'):
|
|
parent = target_path(root, str(PurePosixPath(spool).parent), symlinks=True)
|
|
leaf = parent / 'hydra-live'
|
|
if leaf.is_file() or leaf.is_symlink():
|
|
leaf.unlink()
|
|
|
|
|
|
def select_kernel(root, requested):
|
|
modules = target_path(root, '/lib/modules', symlinks=True)
|
|
if not modules.is_dir():
|
|
raise InstallError('Payload has no installed kernel modules')
|
|
versions = sorted(p.name for p in modules.iterdir() if not p.is_symlink() and p.is_dir() and KERNEL.fullmatch(p.name))
|
|
if requested:
|
|
if requested not in versions:
|
|
raise InstallError(f'Requested kernel not installed: {requested}')
|
|
version = requested
|
|
elif len(versions) == 1:
|
|
version = versions[0]
|
|
else:
|
|
raise InstallError('Select --kernel explicitly when payload has zero or multiple kernels: ' + ', '.join(versions))
|
|
if not target_path(root, '/boot/vmlinuz-' + version, symlinks=True).is_file():
|
|
raise InstallError(f'Payload missing /boot/vmlinuz-{version}')
|
|
return version
|
|
|
|
|
|
def write_config(root, relative, value, mode=0o644):
|
|
path = target_path(root, relative)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(value)
|
|
path.chmod(mode)
|
|
|
|
|
|
def install(request, runner, probe, root, password, log: TextIO):
|
|
"""Execute into an empty mount directory; inject a runner for disk-free tests."""
|
|
if not request.execute:
|
|
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')
|
|
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')
|
|
mounted = []
|
|
error = None
|
|
journal = []
|
|
|
|
def emit(message):
|
|
journal.append(message)
|
|
print(message, file=log, flush=True)
|
|
|
|
def run(argv, **kw):
|
|
emit('+ ' + shlex.join(argv))
|
|
return runner.run(argv, **kw)
|
|
|
|
def mount(argv, path, recursive=False):
|
|
run(argv)
|
|
mounted.append((str(path), recursive))
|
|
|
|
try:
|
|
run(['unsquashfs', '-s', request.payload])
|
|
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)
|
|
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)
|
|
remove_live_account(root, run)
|
|
if not target_path(root, '/usr/share/zoneinfo/' + request.timezone, symlinks=True).is_file():
|
|
raise InstallError('Timezone does not exist in payload: ' + request.timezone)
|
|
for folder in ('boot/efi', 'dev', 'proc', 'sys'):
|
|
target_path(root, folder).mkdir(parents=True, exist_ok=True)
|
|
mount(['mount', esp, str(root / 'boot/efi')], root / 'boot/efi')
|
|
root_uuid = run(['blkid', '-s', 'UUID', '-o', 'value', part]).strip()
|
|
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')
|
|
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')
|
|
localtime = target_path(root, 'etc/localtime', symlinks=True)
|
|
# Replace even an absolute /etc/localtime symlink without touching the host.
|
|
localtime_link = target_path(root, 'etc') / 'localtime'
|
|
if localtime_link.is_symlink():
|
|
localtime_link.unlink()
|
|
elif localtime.exists():
|
|
localtime.unlink()
|
|
(root / 'etc/localtime').symlink_to('/usr/share/zoneinfo/' + request.timezone)
|
|
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'
|
|
+ ('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')])
|
|
mount(['mount', '-t', 'proc', 'proc', str(root / 'proc')], root / 'proc')
|
|
mount(['mount', '--rbind', '/sys', str(root / 'sys')], root / 'sys', True)
|
|
run(['mount', '--make-rslave', str(root / 'sys')])
|
|
def chroot(*args, **kw):
|
|
return run(['chroot', str(root), *args], **kw)
|
|
chroot('locale-gen')
|
|
for group in ('wheel', 'kvm', 'video', 'render', 'audio'):
|
|
chroot('groupadd', '--force', '--system', group)
|
|
chroot('useradd', '--create-home', '--groups', 'wheel,kvm,video,render,audio', '--shell', '/bin/bash', request.user)
|
|
chroot('chpasswd', input=f'{request.user}:{password}\n', sensitive=True)
|
|
password = None
|
|
chroot('passwd', '--lock', 'root')
|
|
chroot('systemd-machine-id-setup')
|
|
chroot('dracut', '--force', '--no-hostonly', '--omit', 'dmsquash-live',
|
|
'/boot/initramfs-' + kernel + '.img', kernel)
|
|
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
|
|
emit(f'Installation stopped: {exc}. Target data retained for diagnosis.')
|
|
finally:
|
|
password = None
|
|
if any(path == str(root) for path, _ in mounted):
|
|
try:
|
|
write_config(root, 'var/log/hydra-install.log', '\n'.join(journal) + '\n', 0o600)
|
|
except (InstallError, OSError) as exc:
|
|
emit(f'Unable to retain diagnostic in target: {exc}')
|
|
cleanup_errors = []
|
|
for path, recursive in reversed(mounted):
|
|
try:
|
|
run(['umount', *(['--recursive'] if recursive else []), path])
|
|
except (InstallError, OSError) as exc:
|
|
cleanup_errors.append(str(exc))
|
|
emit(f'cleanup failed: {exc}')
|
|
if cleanup_errors and error is None:
|
|
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)
|
|
|
|
|
|
def preflight(request, runner):
|
|
if any(Path(marker).exists() for marker in ('/.dockerenv', '/run/.containerenv', '/run/systemd/container')):
|
|
raise InstallError('Refusing disk installation in a container; boot the live ISO in a disposable VM')
|
|
if os.geteuid() != 0:
|
|
raise InstallError('Execution requires root inside the live environment; no automatic sudo')
|
|
if not sys.stdin.isatty():
|
|
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')
|
|
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])
|
|
|
|
|
|
def prompt_password():
|
|
try:
|
|
# Only the prompt output stream is needed here; getpass opens its own
|
|
# raw controlling-TTY input. Buffered r+ requires seekability.
|
|
with open('/dev/tty', 'w') as tty:
|
|
if not tty.isatty():
|
|
raise InstallError('Password entry requires a controlling TTY')
|
|
with warnings.catch_warnings():
|
|
warnings.simplefilter('error', getpass.GetPassWarning)
|
|
password = getpass.getpass('Installed account password: ', stream=tty)
|
|
if password != getpass.getpass('Repeat password: ', stream=tty):
|
|
raise InstallError('Passwords differ')
|
|
return password
|
|
except (OSError, getpass.GetPassWarning) as exc:
|
|
raise InstallError('Cannot securely read password from the controlling TTY') from exc
|
|
|
|
|
|
def main(argv=None):
|
|
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')
|
|
parser.add_argument('--confirm-erase', help='must exactly match the resolved /dev target with --execute')
|
|
parser.add_argument('--timezone', default='Europe/Moscow')
|
|
parser.add_argument('--kernel', help='required if payload contains multiple kernel module versions')
|
|
args = parser.parse_args(argv)
|
|
runner = Runner()
|
|
try:
|
|
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']))
|
|
print('Disks (no target selected; mounted disks are ineligible):')
|
|
for path, node in sorted(topo.nodes.items()):
|
|
if node['type'] == 'disk':
|
|
state = 'in use' if topo.component(path) & topo.protected else 'unmounted; requires full guard check'
|
|
print(f' {path} {node["size"] / 1024**3:.1f} GiB {state}')
|
|
print('Choose --disk DEVICE --user USER to inspect the dry-run plan.')
|
|
return 0
|
|
if not args.user:
|
|
raise InstallError('--user is required with --disk')
|
|
request = Request(**vars(args))
|
|
probe = SystemProbe(runner)
|
|
target = validate_request(request, probe)
|
|
print(render_plan(request, target, '/mnt/hydra-target'))
|
|
if not request.execute:
|
|
return 0
|
|
preflight(request, runner)
|
|
password = prompt_password()
|
|
state = Path(tempfile.mkdtemp(prefix='hydra-install-', dir='/run'))
|
|
state.chmod(0o700)
|
|
root = state / 'target'
|
|
root.mkdir()
|
|
logfile = state / 'installer.log'
|
|
print(f'Diagnostic log: {logfile}')
|
|
with logfile.open('w') as log:
|
|
log.write(render_plan(request, target, str(root)) + '\n')
|
|
install(request, runner, probe, root, password, log)
|
|
password = None
|
|
print(f'Installation completed. Diagnostic log: {logfile}')
|
|
return 0
|
|
except (InstallError, OSError, KeyboardInterrupt) as exc:
|
|
print(f'hydra-install: {exc}', file=sys.stderr)
|
|
return 1
|
|
|
|
|
|
if __name__ == '__main__':
|
|
raise SystemExit(main())
|