diff --git a/checks/installation/flake-module.nix b/checks/installation/flake-module.nix index 90befedba..90fa4f528 100644 --- a/checks/installation/flake-module.nix +++ b/checks/installation/flake-module.nix @@ -216,6 +216,22 @@ "${../assets/ssh/privkey}" ) + # Run clan install from host using port forwarding + clan_cmd = [ + "${self.packages.${pkgs.system}.clan-cli-full}/bin/clan", + "machines", + "init-hardware-config", + "--debug", + "--flake", str(flake_dir), + "--yes", "test-install-machine-without-system", + "--host-key-check", "none", + "--target-host", f"nonrootuser@localhost:{ssh_conn.host_port}", + "-i", ssh_conn.ssh_key, + "--option", "store", os.environ['CLAN_TEST_STORE'] + ] + subprocess.run(clan_cmd, check=True) + + # Run clan install from host using port forwarding clan_cmd = [ "${self.packages.${pkgs.system}.clan-cli-full}/bin/clan", diff --git a/pkgs/clan-app/ui/src/workflows/InstallMachine/steps/installSteps.tsx b/pkgs/clan-app/ui/src/workflows/InstallMachine/steps/installSteps.tsx index 4f825e470..0b4d22642 100644 --- a/pkgs/clan-app/ui/src/workflows/InstallMachine/steps/installSteps.tsx +++ b/pkgs/clan-app/ui/src/workflows/InstallMachine/steps/installSteps.tsx @@ -260,7 +260,7 @@ const CheckHardware = () => { try { // TODO: Debounce - const call = client.fetch("run_machine_hardware_info", { + const call = client.fetch("run_machine_hardware_info_init", { target_host: { address: store.install.targetHost, port, diff --git a/pkgs/clan-cli/clan_cli/cli.py b/pkgs/clan-cli/clan_cli/cli.py index 878d9d35e..29345b8dd 100644 --- a/pkgs/clan-cli/clan_cli/cli.py +++ b/pkgs/clan-cli/clan_cli/cli.py @@ -555,8 +555,8 @@ def main() -> None: else: log.error(e) # noqa: TRY400 sys.exit(1) - except KeyboardInterrupt as ex: - log.warning("Interrupted by user", exc_info=ex) + except KeyboardInterrupt: + log.warning("Interrupted by user") sys.exit(1) diff --git a/pkgs/clan-cli/clan_cli/machines/cli.py b/pkgs/clan-cli/clan_cli/machines/cli.py index 932754f99..bb22df162 100644 --- a/pkgs/clan-cli/clan_cli/machines/cli.py +++ b/pkgs/clan-cli/clan_cli/machines/cli.py @@ -4,7 +4,7 @@ import argparse from .create import register_create_parser from .delete import register_delete_parser from .generations import register_generations_parser -from .hardware import register_update_hardware_config +from .hardware import register_init_hardware_config, register_update_hardware_config from .install import register_install_parser from .list import register_list_parser from .morph import register_morph_parser @@ -89,8 +89,8 @@ Examples: ) register_list_parser(list_parser) - update_hardware_config_parser = subparser.add_parser( - "update-hardware-config", + init_hardware_config_parser = subparser.add_parser( + "init-hardware-config", help="Generate hardware specifics for a machine", description=""" @@ -106,7 +106,7 @@ The target must be a Linux based system reachable via SSH. """ Examples: - $ clan machines update-hardware-config [MACHINE] --target-host root@ + $ clan machines init-hardware-config [MACHINE] --target-host root@ Will generate the facter.json hardware report for `[TARGET_HOST]` and place the result in facter.json for the given machine `[MACHINE]`. For more detailed information, visit: https://docs.clan.lol/guides/getting-started/configure/#machine-configuration @@ -114,6 +114,27 @@ For more detailed information, visit: https://docs.clan.lol/guides/getting-start """ ), ) + register_init_hardware_config(init_hardware_config_parser) + + update_hardware_config_parser = subparser.add_parser( + "update-hardware-config", + help="Generate hardware specifics for a machine", + description=""" + Generates hardware specifics for a machine. Such as the host platform, available kernel modules, etc. + The target must be a Linux based system reachable via SSH + """, + epilog=( + """ + Examples: + + $ clan machines update-hardware-config [MACHINE] --target-host root@ + Will generate the facter.json hardware report for `[TARGET_HOST]` and place the result in facter.json for the given machine `[MACHINE]`. + + For more detailed information, visit: https://docs.clan.lol/guides/getting-started/configure/#machine-configuration + + """ + ), + ) register_update_hardware_config(update_hardware_config_parser) install_parser = subparser.add_parser( diff --git a/pkgs/clan-cli/clan_cli/machines/hardware.py b/pkgs/clan-cli/clan_cli/machines/hardware.py index 8c5887f7e..4350e685e 100644 --- a/pkgs/clan-cli/clan_cli/machines/hardware.py +++ b/pkgs/clan-cli/clan_cli/machines/hardware.py @@ -7,7 +7,8 @@ from clan_lib.flake import require_flake from clan_lib.machines.hardware import ( HardwareConfig, HardwareGenerateOptions, - run_machine_hardware_info, + run_machine_hardware_info_init, + run_machine_hardware_info_update, ) from clan_lib.machines.machines import Machine from clan_lib.machines.suggestions import validate_machine_names @@ -56,11 +57,51 @@ def update_hardware_config_command(args: argparse.Namespace) -> None: log.info("Aborted.") return - run_machine_hardware_info(opts, target_host) + run_machine_hardware_info_update(opts, target_host) -def register_update_hardware_config(parser: argparse.ArgumentParser) -> None: - parser.set_defaults(func=update_hardware_config_command) +def init_hardware_config_command(args: argparse.Namespace) -> None: + flake = require_flake(args.flake) + validate_machine_names([args.machine], flake) + machine = Machine(flake=flake, name=args.machine) + opts = HardwareGenerateOptions( + machine=machine, + password=args.password, + backend=HardwareConfig(args.backend), + ) + + if args.target_host: + target_host = Remote.from_ssh_uri( + machine_name=machine.name, + address=args.target_host, + ) + else: + target_host = machine.target_host() + + target_host = target_host.override( + host_key_check=args.host_key_check, + private_key=args.identity_file, + ) + + if not args.yes: + confirm = ( + input( + "WARNING: This will reboot the target machine into a temporary NixOS system " + "to gather hardware information. This may disrupt any services running on the machine. " + f"Update hardware configuration for machine '{machine.name}' at '{target_host.target}'? [y/N]:" + ) + .strip() + .lower() + ) + if confirm not in ("y", "yes"): + log.info("Aborted.") + return + + run_machine_hardware_info_init(opts, target_host) + + +def register_init_hardware_config(parser: argparse.ArgumentParser) -> None: + parser.set_defaults(func=init_hardware_config_command) machine_parser = parser.add_argument( "machine", help="the name of the machine", @@ -76,7 +117,52 @@ def register_update_hardware_config(parser: argparse.ArgumentParser) -> None: "-y", "--yes", action="store_true", - help="Automatic yes to prompts; assume 'yes' as answer to all prompts and run non-interactively.", + help="do not ask for confirmation.", + ) + parser.add_argument( + "--host-key-check", + choices=list(get_args(HostKeyCheck)), + default="ask", + help="Host key (.ssh/known_hosts) check mode.", + ) + parser.add_argument( + "--password", + help="Pre-provided password the cli will prompt otherwise if needed.", + type=str, + required=False, + ) + parser.add_argument( + "--backend", + help="The type of hardware report to generate.", + choices=["nixos-generate-config", "nixos-facter"], + default="nixos-facter", + ) + parser.add_argument( + "-i", + dest="identity_file", + type=Path, + help="specify which SSH private key file to use", + ) + + +def register_update_hardware_config(parser: argparse.ArgumentParser) -> None: + parser.set_defaults(func=update_hardware_config_command) + machine_parser = parser.add_argument( + "machine", + help="the name of the machine", + type=machine_name_type, + ) + add_dynamic_completer(machine_parser, complete_machines) + parser.add_argument( + "--target-host", + type=str, + help="ssh address to install to in the form of user@host:2222", + ) + parser.add_argument( + "-y", + "--yes", + action="store_true", + help="do not ask for confirmation.", ) parser.add_argument( "--host-key-check", diff --git a/pkgs/clan-cli/clan_lib/machines/hardware.py b/pkgs/clan-cli/clan_lib/machines/hardware.py index 76fe3cc32..4072ab54c 100644 --- a/pkgs/clan-cli/clan_lib/machines/hardware.py +++ b/pkgs/clan-cli/clan_lib/machines/hardware.py @@ -69,7 +69,7 @@ class HardwareGenerateOptions: @API.register -def run_machine_hardware_info( +def run_machine_hardware_info_init( opts: HardwareGenerateOptions, target_host: Remote, ) -> HardwareConfig: @@ -157,6 +157,80 @@ def run_machine_hardware_info( return opts.backend +@API.register +def run_machine_hardware_info_update( + opts: HardwareGenerateOptions, + target_host: Remote, +) -> HardwareConfig: + """Generate hardware information for a machine + and place the resulting *.nix file in the machine's directory. + """ + machine = opts.machine + + hw_file = opts.backend.config_path(opts.machine) + hw_file.parent.mkdir(parents=True, exist_ok=True) + + if opts.backend == HardwareConfig.NIXOS_FACTER: + config_command = ["nixos-facter"] + else: + config_command = [ + "nixos-generate-config", + # Filesystems are managed by disko + "--no-filesystems", + "--show-hardware-config", + ] + + with target_host.host_connection() as ssh, ssh.become_root() as sudo_ssh: + out = sudo_ssh.run(config_command, opts=RunOpts(check=False)) + if out.returncode != 0: + if "nixos-facter" in out.stderr and "not found" in out.stderr: + machine.error(str(out.stderr)) + msg = ( + "Please use our custom nixos install images from https://github.com/nix-community/nixos-images/releases/tag/nixos-unstable. " + "nixos-factor only works on nixos / clan systems currently." + ) + raise ClanError(msg) + + machine.error(str(out)) + msg = f"Failed to inspect {opts.machine}. Address: {target_host.target}" + raise ClanError(msg) + + backup_file = None + if hw_file.exists(): + backup_file = hw_file.with_suffix(".bak") + hw_file.replace(backup_file) + hw_file.write_text(out.stdout) + print(f"Successfully generated: {hw_file}") + + # try to evaluate the machine + # If it fails, the hardware-configuration.nix file is invalid + + commit_file( + hw_file, + opts.machine.flake.path, + f"machines/{opts.machine.name}/{hw_file.name}: update hardware configuration", + ) + try: + get_machine_target_platform(opts.machine) + if backup_file: + backup_file.unlink(missing_ok=True) + except ClanCmdError as e: + log.exception("Failed to evaluate hardware-configuration.nix") + # Restore the backup file + print(f"Restoring backup file {backup_file}") + if backup_file: + backup_file.replace(hw_file) + # TODO: Undo the commit + + msg = "Invalid hardware-configuration.nix file" + raise ClanError( + msg, + description=f"Configuration at '{hw_file}' is invalid. Please check the file and try again.", + ) from e + + return opts.backend + + def get_machine_hardware_config(machine: Machine) -> HardwareConfig: """Detect and return the full hardware configuration for the given machine. diff --git a/pkgs/clan-cli/clan_lib/machines/update.py b/pkgs/clan-cli/clan_lib/machines/update.py index c2dfb5503..8289f6656 100644 --- a/pkgs/clan-cli/clan_lib/machines/update.py +++ b/pkgs/clan-cli/clan_lib/machines/update.py @@ -122,7 +122,7 @@ def upload_sources(machine: Machine, ssh: Host, upload_inputs: bool) -> str: def run_machine_update( machine: Machine, target_host: Remote | LocalHost, - build_host: Remote | LocalHost | None, + build_host: Remote | LocalHost | None = None, upload_inputs: bool = False, ) -> None: """Update an existing machine using nixos-rebuild or darwin-rebuild. diff --git a/pkgs/clan-cli/clan_lib/ssh/remote.py b/pkgs/clan-cli/clan_lib/ssh/remote.py index 6540302c4..e2e80a3ad 100644 --- a/pkgs/clan-cli/clan_lib/ssh/remote.py +++ b/pkgs/clan-cli/clan_lib/ssh/remote.py @@ -64,6 +64,8 @@ class Remote: def override( self, *, + user: str | None = None, + address: str | None = None, host_key_check: HostKeyCheck | None = None, private_key: Path | None = None, password: str | None = None, @@ -75,8 +77,8 @@ class Remote: ) -> "Remote": """Returns a new Remote instance with the same data but with a different host_key_check.""" return Remote( - address=self.address, - user=self.user, + address=address or self.address, + user=user or self.user, command_prefix=command_prefix or self.command_prefix, port=port or self.port, private_key=private_key if private_key is not None else self.private_key,