mirror of
https://github.com/basecamp/omarchy.git
synced 2026-02-17 15:25:37 +00:00
345 lines
11 KiB
Python
Executable File
345 lines
11 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Omarchy Disk Configuration Tool
|
|
|
|
Interactive partition editor and validator for Omarchy installations.
|
|
"""
|
|
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
|
|
def _find_root_partition(disk_config):
|
|
for dev_mod in disk_config.device_modifications:
|
|
for part in dev_mod.partitions:
|
|
if part.mountpoint == Path('/'):
|
|
return part
|
|
|
|
if part.btrfs_subvols:
|
|
for subvol in part.btrfs_subvols:
|
|
if subvol.mountpoint == Path('/'):
|
|
return part
|
|
|
|
return None
|
|
|
|
|
|
def validate_disk_config(config, interactive=True):
|
|
from archinstall.lib.models.device import FilesystemType, Size, Unit, EncryptionType
|
|
from archinstall.lib.output import info, warn
|
|
|
|
if not config.disk_config:
|
|
return 'CONTINUE'
|
|
|
|
validation_warnings = []
|
|
|
|
boot_partition = None
|
|
for dev_mod in config.disk_config.device_modifications:
|
|
for part in dev_mod.partitions:
|
|
if part.mountpoint == Path('/boot') or part.mountpoint == Path('/efi'):
|
|
boot_partition = part
|
|
break
|
|
|
|
if boot_partition:
|
|
min_boot_size = Size(2, Unit.GiB, boot_partition.length.sector_size)
|
|
if boot_partition.length >= min_boot_size:
|
|
size_gb = boot_partition.length.convert(Unit.GiB).value
|
|
info(f'✓ Boot partition size: {size_gb:.1f} GiB')
|
|
else:
|
|
size_mb = boot_partition.length.convert(Unit.MiB).value
|
|
warn(f'⚠ Boot partition is only {size_mb:.0f} MiB')
|
|
warn(' Omarchy recommends at least 2 GiB for boot partition')
|
|
warn(' Multiple kernels may not fit')
|
|
validation_warnings.append('boot_size')
|
|
else:
|
|
warn('⚠ Could not find boot partition (/boot or /efi)')
|
|
warn(' System may not boot correctly')
|
|
validation_warnings.append('no_boot')
|
|
|
|
root_partition = _find_root_partition(config.disk_config)
|
|
|
|
if root_partition:
|
|
if root_partition.fs_type == FilesystemType.Btrfs:
|
|
info('✓ Root filesystem is btrfs')
|
|
|
|
if root_partition.btrfs_subvols:
|
|
subvol_names = [str(sv.name) for sv in root_partition.btrfs_subvols]
|
|
subvol_mounts = {str(sv.mountpoint): str(sv.name) for sv in root_partition.btrfs_subvols}
|
|
|
|
required_subvols = {
|
|
'/': '@',
|
|
'/home': '@home',
|
|
'/var/log': '@log',
|
|
'/var/cache/pacman/pkg': '@pkg',
|
|
}
|
|
|
|
missing_subvols = []
|
|
for mount, expected_name in required_subvols.items():
|
|
if mount not in subvol_mounts:
|
|
missing_subvols.append(f'{expected_name} → {mount}')
|
|
elif subvol_mounts[mount] != expected_name:
|
|
warn(f'⚠ Subvolume at {mount} is named "{subvol_mounts[mount]}" not "{expected_name}"')
|
|
|
|
if missing_subvols:
|
|
warn(f'⚠ Missing recommended subvolumes: {", ".join(missing_subvols)}')
|
|
warn(' Omarchy recommends: @, @home, @log, @pkg')
|
|
warn(' Some features (like Snapper) may not work optimally')
|
|
info(f' Current subvolumes: {", ".join(subvol_names)}')
|
|
validation_warnings.append('missing_subvols')
|
|
else:
|
|
info(f'✓ Btrfs subvolumes: {", ".join(subvol_names)}')
|
|
else:
|
|
warn('⚠ Btrfs partition has no subvolumes defined')
|
|
warn(' Omarchy recommends subvolumes for snapshots')
|
|
warn(' Required: @ (root), @home, @log, @pkg')
|
|
validation_warnings.append('no_subvols')
|
|
else:
|
|
fs_name = root_partition.fs_type.value if root_partition.fs_type else 'unknown'
|
|
warn(f'⚠ Root filesystem is {fs_name}, not btrfs')
|
|
warn(' Omarchy is designed for btrfs with snapshots')
|
|
warn(' Some features may not work correctly')
|
|
validation_warnings.append('not_btrfs')
|
|
|
|
is_encrypted = False
|
|
if config.disk_config.disk_encryption:
|
|
enc = config.disk_config.disk_encryption
|
|
if enc.encryption_type != EncryptionType.NoEncryption:
|
|
is_encrypted = root_partition in enc.partitions
|
|
|
|
if is_encrypted:
|
|
info('✓ Root partition is encrypted with LUKS')
|
|
|
|
if config.disk_config.disk_encryption.iter_time != 2000:
|
|
old_time = config.disk_config.disk_encryption.iter_time
|
|
config.disk_config.disk_encryption.iter_time = 2000
|
|
info(f'✓ Adjusted iteration time: {old_time}ms → 2000ms')
|
|
else:
|
|
info('✓ Iteration time: 2000ms (optimal)')
|
|
else:
|
|
warn('⚠ Root partition is NOT encrypted')
|
|
warn(' Omarchy recommends LUKS encryption for security')
|
|
validation_warnings.append('no_encryption')
|
|
else:
|
|
warn('⚠ Could not identify root partition')
|
|
validation_warnings.append('no_root')
|
|
|
|
if validation_warnings and interactive:
|
|
import subprocess
|
|
import shutil
|
|
|
|
gum_path = shutil.which('gum')
|
|
if gum_path:
|
|
warn('')
|
|
|
|
try:
|
|
result = subprocess.run(
|
|
['gum', 'choose', '--header', 'Validation warnings detected. What would you like to do?',
|
|
'Re-edit partitions', 'Continue anyway', 'Abort'],
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
|
|
choice = result.stdout.strip() if result.stdout else ''
|
|
|
|
if choice == 'Re-edit partitions':
|
|
return 'RE_EDIT'
|
|
elif choice == 'Abort':
|
|
return 'ABORT'
|
|
else:
|
|
info('Continuing despite warnings')
|
|
return 'CONTINUE'
|
|
|
|
except (subprocess.CalledProcessError, KeyboardInterrupt):
|
|
return 'ABORT'
|
|
|
|
return 'CONTINUE'
|
|
|
|
|
|
def apply_omarchy_partition_defaults():
|
|
import archinstall.lib.interactions.disk_conf as disk_conf_module
|
|
from archinstall.lib.models.device import (
|
|
PartitionModification, ModificationStatus, PartitionType,
|
|
Size, Unit, SectorSize, FilesystemType, PartitionFlag,
|
|
DeviceModification, BDevice
|
|
)
|
|
from archinstall.lib.interactions.disk_conf import get_default_btrfs_subvols
|
|
from archinstall.lib.disk.device_handler import device_handler
|
|
|
|
def _boot_partition_2gib(sector_size: SectorSize, using_gpt: bool) -> PartitionModification:
|
|
flags = [PartitionFlag.BOOT]
|
|
size = Size(2, Unit.GiB, sector_size)
|
|
start = Size(1, Unit.MiB, sector_size)
|
|
if using_gpt:
|
|
flags.append(PartitionFlag.ESP)
|
|
|
|
return PartitionModification(
|
|
status=ModificationStatus.Create,
|
|
type=PartitionType.Primary,
|
|
start=start,
|
|
length=size,
|
|
mountpoint=Path('/boot'),
|
|
fs_type=FilesystemType.Fat32,
|
|
flags=flags,
|
|
)
|
|
|
|
def _select_main_filesystem_btrfs() -> FilesystemType:
|
|
return FilesystemType.Btrfs
|
|
|
|
def _select_mount_options_compressed() -> list[str]:
|
|
return ['compress=zstd']
|
|
|
|
def _suggest_single_disk_auto_subvolumes(
|
|
device: BDevice,
|
|
filesystem_type: FilesystemType | None = None,
|
|
separate_home: bool | None = None,
|
|
):
|
|
if not filesystem_type:
|
|
filesystem_type = FilesystemType.Btrfs
|
|
|
|
if filesystem_type == FilesystemType.Btrfs:
|
|
using_subvolumes = True
|
|
mount_options = ['compress=zstd']
|
|
else:
|
|
using_subvolumes = False
|
|
mount_options = []
|
|
|
|
sector_size = device.device_info.sector_size
|
|
device_modification = DeviceModification(device, wipe=True)
|
|
using_gpt = device_handler.partition_table.is_gpt()
|
|
|
|
boot_partition = _boot_partition_2gib(sector_size, using_gpt)
|
|
device_modification.add_partition(boot_partition)
|
|
|
|
total_size = device.device_info.total_size
|
|
available_space = total_size - boot_partition.length - Size(1, Unit.MiB, sector_size)
|
|
|
|
root_partition = PartitionModification(
|
|
status=ModificationStatus.Create,
|
|
type=PartitionType.Primary,
|
|
start=boot_partition.start + boot_partition.length,
|
|
length=available_space,
|
|
mountpoint=None if using_subvolumes else Path('/'),
|
|
fs_type=filesystem_type,
|
|
mount_options=mount_options,
|
|
)
|
|
|
|
if using_subvolumes:
|
|
root_partition.btrfs_subvols = get_default_btrfs_subvols()
|
|
|
|
device_modification.add_partition(root_partition)
|
|
return device_modification
|
|
|
|
disk_conf_module._boot_partition = _boot_partition_2gib
|
|
disk_conf_module.select_main_filesystem_format = _select_main_filesystem_btrfs
|
|
disk_conf_module.select_mount_options = _select_mount_options_compressed
|
|
disk_conf_module.suggest_single_disk_layout = _suggest_single_disk_auto_subvolumes
|
|
|
|
|
|
def load_config(config_file: Path, creds_file: Path | None = None):
|
|
from archinstall.lib.args import ArchConfig, Arguments
|
|
|
|
with open(config_file) as f:
|
|
config_data = json.load(f)
|
|
|
|
if creds_file and creds_file.exists():
|
|
with open(creds_file) as f:
|
|
creds_data = json.load(f)
|
|
config_data.update(creds_data)
|
|
|
|
args = Arguments(
|
|
config=config_file,
|
|
creds=creds_file,
|
|
mountpoint=Path('/mnt'),
|
|
silent=True,
|
|
)
|
|
|
|
return ArchConfig.from_config(config_data, args)
|
|
|
|
|
|
def save_config(config, output_file: Path):
|
|
from archinstall.lib.output import info
|
|
|
|
try:
|
|
config_dict = config.safe_json()
|
|
with open(output_file, 'w') as f:
|
|
json.dump(config_dict, f, indent=2, default=str)
|
|
info(f'✓ Configuration saved to: {output_file}')
|
|
except Exception as e:
|
|
from archinstall.lib.output import error
|
|
error(f'Failed to save config: {e}')
|
|
raise
|
|
|
|
|
|
def main():
|
|
import argparse
|
|
|
|
parser = argparse.ArgumentParser(description='Omarchy Disk Configuration Tool')
|
|
parser.add_argument('--config', type=Path, required=True, help='Path to config file')
|
|
parser.add_argument('--creds', type=Path, help='Path to credentials file')
|
|
parser.add_argument('--output', type=Path, help='Output path (default: overwrites input)')
|
|
parser.add_argument('--non-interactive', action='store_true', help='Skip interactive prompts')
|
|
|
|
args = parser.parse_args()
|
|
|
|
output_file = args.output or args.config
|
|
|
|
if not args.config.exists():
|
|
print(f'ERROR: Config file not found: {args.config}', file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
if args.creds and not args.creds.exists():
|
|
print(f'ERROR: Credentials file not found: {args.creds}', file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
try:
|
|
from archinstall.lib.output import info, error
|
|
from archinstall.lib.disk.disk_menu import DiskLayoutConfigurationMenu
|
|
from archinstall.tui.curses_menu import Tui
|
|
|
|
apply_omarchy_partition_defaults()
|
|
|
|
info('Loading configuration...')
|
|
config = load_config(args.config, args.creds)
|
|
|
|
if not config.disk_config:
|
|
error('No disk configuration found in config file')
|
|
sys.exit(1)
|
|
|
|
while True:
|
|
info('Launching partition editor...')
|
|
|
|
with Tui():
|
|
edited_disk_config = DiskLayoutConfigurationMenu(config.disk_config).run()
|
|
if edited_disk_config:
|
|
config.disk_config = edited_disk_config
|
|
info('✓ Partition configuration updated')
|
|
else:
|
|
info('No changes made in partition editor')
|
|
|
|
interactive = not args.non_interactive
|
|
validation_result = validate_disk_config(config, interactive=interactive)
|
|
|
|
if validation_result == 'RE_EDIT':
|
|
continue
|
|
elif validation_result == 'ABORT':
|
|
info('Disk configuration cancelled by user')
|
|
sys.exit(1)
|
|
else:
|
|
break
|
|
|
|
save_config(config, output_file)
|
|
info('✓ Disk configuration complete!')
|
|
|
|
except KeyboardInterrupt:
|
|
print('\nCancelled by user', file=sys.stderr)
|
|
sys.exit(1)
|
|
except Exception as e:
|
|
print(f'ERROR: {e}', file=sys.stderr)
|
|
import traceback
|
|
traceback.print_exc()
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|