Merge pull request 'clan flash: Remove root requirement for flash, add a flash-template' (#2165) from Qubasa/clan-core:Qubasa-main into main

This commit is contained in:
clan-bot
2024-09-24 11:48:57 +00:00
10 changed files with 191 additions and 73 deletions

View File

@@ -1,27 +1,21 @@
import logging import logging
import os
import shutil import shutil
from collections.abc import Generator from collections.abc import Generator
from contextlib import contextmanager from contextlib import contextmanager
from pathlib import Path from pathlib import Path
from clan_cli.cmd import run from clan_cli.cmd import Log, run
from clan_cli.errors import ClanError from clan_cli.errors import ClanError
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@contextmanager @contextmanager
def pause_automounting( def pause_automounting(devices: list[Path]) -> Generator[None, None, None]:
devices: list[Path], no_udev: bool
) -> Generator[None, None, None]:
""" """
Pause automounting on the device for the duration of this context Pause automounting on the device for the duration of this context
manager manager
""" """
if no_udev:
yield None
return
if shutil.which("udevadm") is None: if shutil.which("udevadm") is None:
msg = "udev is required to disable automounting" msg = "udev is required to disable automounting"
@@ -29,37 +23,18 @@ def pause_automounting(
yield None yield None
return return
if os.geteuid() != 0: inhibit_path = Path(__file__).parent / "inhibit.sh"
msg = "root privileges are required to disable automounting" if not inhibit_path.exists():
msg = f"{inhibit_path} not found"
raise ClanError(msg) raise ClanError(msg)
try:
# See /usr/lib/udisks2/udisks2-inhibit
rules_dir = Path("/run/udev/rules.d")
rules_dir.mkdir(exist_ok=True)
rule_files: list[Path] = []
for device in devices:
devpath: str = str(device)
rule_file: Path = (
rules_dir / f"90-udisks-inhibit-{devpath.replace('/', '_')}.rules"
)
with rule_file.open("w") as fd:
print(
'SUBSYSTEM=="block", ENV{DEVNAME}=="'
+ devpath
+ '*", ENV{UDISKS_IGNORE}="1"',
file=fd,
)
fd.flush()
os.fsync(fd.fileno())
rule_files.append(rule_file)
run(["udevadm", "control", "--reload"])
run(["udevadm", "trigger", "--settle", "--subsystem-match=block"])
str_devs = [str(dev) for dev in devices]
cmd = ["sudo", str(inhibit_path), "enable", *str_devs]
result = run(cmd, log=Log.BOTH, check=False)
if result.returncode != 0:
log.error("Failed to inhibit automounting")
yield None yield None
except Exception as ex: cmd = ["sudo", str(inhibit_path), "disable", *str_devs]
log.fatal(ex) result = run(cmd, log=Log.BOTH, check=False)
finally: if result.returncode != 0:
for rule_file in rule_files: log.error("Failed to re-enable automounting")
rule_file.unlink(missing_ok=True)
run(["udevadm", "control", "--reload"], check=False)
run(["udevadm", "trigger", "--settle", "--subsystem-match=block"], check=False)

View File

@@ -11,9 +11,11 @@ from typing import Any
from clan_cli.api import API from clan_cli.api import API
from clan_cli.cmd import Log, run from clan_cli.cmd import Log, run
from clan_cli.errors import ClanError from clan_cli.errors import ClanError
from clan_cli.facts.generate import generate_facts
from clan_cli.facts.secret_modules import SecretStoreBase from clan_cli.facts.secret_modules import SecretStoreBase
from clan_cli.machines.machines import Machine from clan_cli.machines.machines import Machine
from clan_cli.nix import nix_shell from clan_cli.nix import nix_shell
from clan_cli.vars.generate import generate_vars_for_machine
from .automount import pause_automounting from .automount import pause_automounting
from .list import list_possible_keymaps, list_possible_languages from .list import list_possible_keymaps, list_possible_languages
@@ -24,7 +26,6 @@ log = logging.getLogger(__name__)
@dataclass @dataclass
class WifiConfig: class WifiConfig:
ssid: str ssid: str
password: str
@dataclass @dataclass
@@ -51,19 +52,21 @@ def flash_machine(
dry_run: bool, dry_run: bool,
write_efi_boot_entries: bool, write_efi_boot_entries: bool,
debug: bool, debug: bool,
no_udev: bool = False,
extra_args: list[str] | None = None, extra_args: list[str] | None = None,
) -> None: ) -> None:
devices = [Path(disk.device) for disk in disks] devices = [Path(disk.device) for disk in disks]
with pause_automounting(devices, no_udev): with pause_automounting(devices):
if extra_args is None: if extra_args is None:
extra_args = [] extra_args = []
system_config_nix: dict[str, Any] = {} system_config_nix: dict[str, Any] = {}
generate_vars_for_machine(machine, generator_name=None, regenerate=False)
generate_facts([machine], service=None, regenerate=False)
if system_config.wifi_settings: if system_config.wifi_settings:
wifi_settings = {} wifi_settings: dict[str, dict[str, str]] = {}
for wifi in system_config.wifi_settings: for wifi in system_config.wifi_settings:
wifi_settings[wifi.ssid] = {"password": wifi.password} wifi_settings[wifi.ssid] = {}
system_config_nix["clan"] = {"iwd": {"networks": wifi_settings}} system_config_nix["clan"] = {"iwd": {"networks": wifi_settings}}
if system_config.language: if system_config.language:

View File

@@ -26,7 +26,6 @@ class FlashOptions:
mode: str mode: str
write_efi_boot_entries: bool write_efi_boot_entries: bool
nix_options: list[str] nix_options: list[str]
no_udev: bool
system_config: SystemConfig system_config: SystemConfig
@@ -73,15 +72,11 @@ def flash_command(args: argparse.Namespace) -> None:
wifi_settings=None, wifi_settings=None,
), ),
write_efi_boot_entries=args.write_efi_boot_entries, write_efi_boot_entries=args.write_efi_boot_entries,
no_udev=args.no_udev,
nix_options=args.option, nix_options=args.option,
) )
if args.wifi: if args.wifi:
opts.system_config.wifi_settings = [ opts.system_config.wifi_settings = [WifiConfig(ssid=ssid) for ssid in args.wifi]
WifiConfig(ssid=ssid, password=password)
for ssid, password in args.wifi.items()
]
machine = Machine(opts.machine, flake=opts.flake) machine = Machine(opts.machine, flake=opts.flake)
if opts.confirm and not opts.dry_run: if opts.confirm and not opts.dry_run:
@@ -102,7 +97,6 @@ def flash_command(args: argparse.Namespace) -> None:
dry_run=opts.dry_run, dry_run=opts.dry_run,
debug=opts.debug, debug=opts.debug,
write_efi_boot_entries=opts.write_efi_boot_entries, write_efi_boot_entries=opts.write_efi_boot_entries,
no_udev=opts.no_udev,
extra_args=opts.nix_options, extra_args=opts.nix_options,
) )
@@ -135,11 +129,9 @@ def register_flash_apply_parser(parser: argparse.ArgumentParser) -> None:
parser.add_argument( parser.add_argument(
"--wifi", "--wifi",
type=str, type=str,
nargs=2, action="append",
metavar=("ssid", "password"), help="wifi ssid to connect to",
action=AppendDiskAction, default=[],
help="wifi network to connect to",
default={},
) )
parser.add_argument( parser.add_argument(
"--mode", "--mode",
@@ -160,12 +152,6 @@ def register_flash_apply_parser(parser: argparse.ArgumentParser) -> None:
type=str, type=str,
help="system language", help="system language",
) )
parser.add_argument(
"--no-udev",
help="Disable udev rules to block automounting",
default=False,
action="store_true",
)
parser.add_argument( parser.add_argument(
"--keymap", "--keymap",
type=str, type=str,

View File

@@ -0,0 +1,78 @@
#!/usr/bin/env bash
# A function to log error messages
log_fatal() {
echo "FATAL: $*" >&2
}
# A function to log warning messages
log_warning() {
echo "WARNING: $*" >&2
}
# Function to enable inhibition (pause automounting)
enable_inhibition() {
local devices=("$@")
if ! command -v udevadm &> /dev/null; then
log_fatal "udev is required to disable automounting"
exit 1
fi
if [ "$EUID" -ne 0 ]; then
log_fatal "Root privileges are required to disable automounting"
exit 1
fi
local rules_dir="/run/udev/rules.d"
mkdir -p "$rules_dir"
for device in "${devices[@]}"; do
local devpath="$device"
local rule_file="$rules_dir/90-udisks-inhibit-${devpath//\//_}.rules"
echo 'SUBSYSTEM=="block", ENV{DEVNAME}=="'"$devpath"'*", ENV{UDISKS_IGNORE}="1"' > "$rule_file"
sync
done
udevadm control --reload
udevadm trigger --settle --subsystem-match=block
}
# Function to disable inhibition (cleanup)
disable_inhibition() {
local devices=("$@")
local rules_dir="/run/udev/rules.d"
for device in "${devices[@]}"; do
local devpath="$device"
local rule_file="$rules_dir/90-udisks-inhibit-${devpath//\//_}.rules"
rm -f "$rule_file" || log_warning "Could not remove file: $rule_file"
done
udevadm control --reload || log_warning "Could not reload udev rules"
udevadm trigger --settle --subsystem-match=block || log_warning "Could not trigger udev settle"
}
if [ "$#" -lt 2 ]; then
echo "Usage: $0 [enable|disable] /dev/sdX ..."
exit 1
fi
action=$1
shift
devices=("$@")
case "$action" in
enable)
enable_inhibition "${devices[@]}"
;;
disable)
disable_inhibition "${devices[@]}"
;;
*)
echo "Invalid action: $action"
echo "Usage: $0 [enable|disable] /dev/sdX ..."
exit 1
;;
esac

View File

@@ -171,24 +171,25 @@ def merge_template_inventory(
) )
raise ClanError(msg, description=description) raise ClanError(msg, description=description)
# Get the service config without knowing instance name # services.<service_name>.<instance_name>.config
service_conf = next((v for v in instance.values() if "config" in v), None) config = next((v for v in instance.values() if "config" in v), None)
if not config:
if not service_conf:
msg = f"Service {service_name} in template inventory has no config" msg = f"Service {service_name} in template inventory has no config"
description = "Invalid inventory configuration" description = "Invalid inventory configuration"
raise ClanError(msg, description=description) raise ClanError(msg, description=description)
if "machines" in service_conf: # Disallow "config.machines" key
if "machines" in config:
msg = f"Service {service_name} in template inventory has machines" msg = f"Service {service_name} in template inventory has machines"
description = "The 'machines' key is not allowed in template inventory" description = "The 'machines' key is not allowed in template inventory"
raise ClanError(msg, description=description) raise ClanError(msg, description=description)
if "roles" not in service_conf: # Require "config.roles" key
if "roles" not in config:
msg = f"Service {service_name} in template inventory has no roles" msg = f"Service {service_name} in template inventory has no roles"
description = "roles key is required in template inventory" description = "roles key is required in template inventory"
raise ClanError(msg, description=description) raise ClanError(msg, description=description)
# TODO: We need a MachineReference type in nix before we can implement this properly # TODO: Implement merging of template inventory
msg = "Merge template inventory is not implemented yet" msg = "Merge template inventory is not implemented yet"
raise NotImplementedError(msg) raise NotImplementedError(msg)

View File

@@ -3,11 +3,16 @@ from dataclasses import dataclass
from functools import cached_property from functools import cached_property
from graphlib import TopologicalSorter from graphlib import TopologicalSorter
from clan_cli.errors import ClanError
from clan_cli.machines.machines import Machine from clan_cli.machines.machines import Machine
from .check import check_vars from .check import check_vars
class GeneratorNotFoundError(ClanError):
pass
@dataclass @dataclass
class Generator: class Generator:
name: str name: str
@@ -28,6 +33,11 @@ def missing_dependency_closure(
queue = list(closure) queue = list(closure)
while queue: while queue:
gen_name = queue.pop(0) gen_name = queue.pop(0)
if gen_name not in generators:
msg = f"Requested generator {gen_name} not found"
raise GeneratorNotFoundError(msg)
for dep in generators[gen_name].dependencies: for dep in generators[gen_name].dependencies:
if dep not in closure and not generators[dep].exists: if dep not in closure and not generators[dep].exists:
dep_closure.add(dep) dep_closure.add(dep)

View File

@@ -25,6 +25,7 @@ clan_cli = [
"templates/**/*", "templates/**/*",
"vms/mimetypes/**/*", "vms/mimetypes/**/*",
"webui/assets/**/*", "webui/assets/**/*",
"flash/*.sh"
] ]
[tool.pytest.ini_options] [tool.pytest.ini_options]

View File

@@ -160,7 +160,6 @@ export const Flash = () => {
dry_run: false, dry_run: false,
write_efi_boot_entries: false, write_efi_boot_entries: false,
debug: false, debug: false,
no_udev: true,
}); });
} catch (error) { } catch (error) {
toast.error(`Error could not flash disk: ${error}`); toast.error(`Error could not flash disk: ${error}`);

View File

@@ -1,7 +1,18 @@
{
config,
clan-core,
inputs,
...
}:
{ {
imports = [ imports = [
./disko.nix
clan-core.nixosModules.installer
clan-core.clanModules.trusted-nix-caches
clan-core.clanModules.disk-id
clan-core.clanModules.iwd
]; ];
# Flash machine template nixpkgs.pkgs = inputs.nixpkgs.legacyPackages.x86_64-linux;
system.stateVersion = config.system.nixos.version;
} }

View File

@@ -0,0 +1,54 @@
{ config, lib, ... }:
let
suffix = config.clan.core.vars.generators.disk-id.files.diskId.value;
in
{
boot.loader.grub.efiSupport = lib.mkDefault true;
boot.loader.grub.efiInstallAsRemovable = lib.mkDefault true;
disko.devices = {
disk = {
"main" = {
name = "main-" + suffix;
type = "disk";
device = lib.mkDefault "/dev/null";
content = {
type = "gpt";
partitions = {
"boot" = {
size = "1M";
type = "EF02"; # for grub MBR
priority = 1;
};
"ESP" = {
size = "512M";
type = "EF00";
content = {
type = "filesystem";
format = "vfat";
mountpoint = "/boot";
};
};
root = {
name = "root";
end = "-0";
content = {
type = "filesystem";
format = "f2fs";
mountpoint = "/";
extraArgs = [
"-O"
"extra_attr,inode_checksum,sb_checksum,compression"
];
# Recommendations for flash: https://wiki.archlinux.org/title/F2FS#Recommended_mount_options
mountOptions = [
"compress_algorithm=zstd:6,compress_chksum,atgc,gc_merge,lazytime,nodiscard"
];
};
};
};
};
};
};
};
}