From 4586b0d17d2f1f11adaeed97a971aa67d4e23f5e Mon Sep 17 00:00:00 2001 From: Brian McGee Date: Fri, 20 Jun 2025 09:13:55 +0100 Subject: [PATCH 1/5] feat(ui): rename TagStatus to MachineStatus Standardizes naming and updates related props, classes, and types for clarity and consistency. --- .../ui/src/components/v2/MachineStatus/MachineStatus.css | 2 +- .../components/v2/MachineStatus/MachineStatus.stories.tsx | 6 +++--- .../ui/src/components/v2/MachineStatus/MachineStatus.tsx | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/pkgs/clan-app/ui/src/components/v2/MachineStatus/MachineStatus.css b/pkgs/clan-app/ui/src/components/v2/MachineStatus/MachineStatus.css index adb1f2f8e..a24ae0018 100644 --- a/pkgs/clan-app/ui/src/components/v2/MachineStatus/MachineStatus.css +++ b/pkgs/clan-app/ui/src/components/v2/MachineStatus/MachineStatus.css @@ -1,4 +1,4 @@ -span.tag-status { +span.machine-status { @apply flex items-center gap-1; .indicator { diff --git a/pkgs/clan-app/ui/src/components/v2/MachineStatus/MachineStatus.stories.tsx b/pkgs/clan-app/ui/src/components/v2/MachineStatus/MachineStatus.stories.tsx index 0c8a597ca..d9adc857d 100644 --- a/pkgs/clan-app/ui/src/components/v2/MachineStatus/MachineStatus.stories.tsx +++ b/pkgs/clan-app/ui/src/components/v2/MachineStatus/MachineStatus.stories.tsx @@ -1,10 +1,10 @@ import { MachineStatus, - TagStatusProps, + MachineStatusProps, } from "@/src/components/v2/MachineStatus/MachineStatus"; import { Meta, StoryObj } from "@kachurun/storybook-solid"; -const meta: Meta = { +const meta: Meta = { title: "Components/MachineStatus", component: MachineStatus, decorators: [ @@ -18,7 +18,7 @@ const meta: Meta = { export default meta; -type Story = StoryObj; +type Story = StoryObj; export const Online: Story = { args: { diff --git a/pkgs/clan-app/ui/src/components/v2/MachineStatus/MachineStatus.tsx b/pkgs/clan-app/ui/src/components/v2/MachineStatus/MachineStatus.tsx index 1fcb523d7..95ffa2e2d 100644 --- a/pkgs/clan-app/ui/src/components/v2/MachineStatus/MachineStatus.tsx +++ b/pkgs/clan-app/ui/src/components/v2/MachineStatus/MachineStatus.tsx @@ -12,14 +12,14 @@ export type MachineStatus = | "Installed" | "Not Installed"; -export interface TagStatusProps { +export interface MachineStatusProps { label?: boolean; status: MachineStatus; } -export const MachineStatus = (props: TagStatusProps) => ( +export const MachineStatus = (props: MachineStatusProps) => ( Date: Fri, 20 Jun 2025 09:23:03 +0100 Subject: [PATCH 2/5] feat(ui): add Tag component Adds a reusable `Tag` component with support for default and inverted styles. Also includes cleanup of unused dependencies in `package.json`. --- pkgs/clan-app/ui/package.json | 3 -- .../clan-app/ui/src/components/v2/Tag/Tag.css | 37 +++++++++++++ .../ui/src/components/v2/Tag/Tag.stories.tsx | 47 ++++++++++++++++ .../clan-app/ui/src/components/v2/Tag/Tag.tsx | 54 +++++++++++++++++++ 4 files changed, 138 insertions(+), 3 deletions(-) create mode 100644 pkgs/clan-app/ui/src/components/v2/Tag/Tag.css create mode 100644 pkgs/clan-app/ui/src/components/v2/Tag/Tag.stories.tsx create mode 100644 pkgs/clan-app/ui/src/components/v2/Tag/Tag.tsx diff --git a/pkgs/clan-app/ui/package.json b/pkgs/clan-app/ui/package.json index 1d30e84b8..649a7e54d 100644 --- a/pkgs/clan-app/ui/package.json +++ b/pkgs/clan-app/ui/package.json @@ -28,10 +28,8 @@ "@storybook/addon-a11y": "^9.0.8", "@storybook/addon-docs": "^9.0.8", "@storybook/addon-links": "^9.0.8", - "@storybook/addon-onboarding": "^9.0.8", "@storybook/addon-viewport": "^9.0.8", "@storybook/addon-vitest": "^9.0.8", - "@tailwindcss/typography": "^0.5.13", "@types/node": "^22.15.19", "@types/three": "^0.176.0", "@typescript-eslint/parser": "^8.32.1", @@ -68,7 +66,6 @@ "@modular-forms/solid": "^0.25.1", "@solid-primitives/storage": "^4.3.2", "@solidjs/router": "^0.15.3", - "@solidjs/testing-library": "^0.8.10", "@tanstack/eslint-plugin-query": "^5.51.12", "@tanstack/solid-query": "^5.76.0", "solid-js": "^1.9.7", diff --git a/pkgs/clan-app/ui/src/components/v2/Tag/Tag.css b/pkgs/clan-app/ui/src/components/v2/Tag/Tag.css new file mode 100644 index 000000000..8c7d2c51c --- /dev/null +++ b/pkgs/clan-app/ui/src/components/v2/Tag/Tag.css @@ -0,0 +1,37 @@ +span.tag { + @apply flex items-center gap-1 w-fit px-2 py-1 rounded-full; + @apply bg-def-4; + + &:focus-visible { + @apply bg-def-acc-3 outline-none; + box-shadow: + 0 0 0 0.0625rem theme(colors.off.white), + 0 0 0 0.125rem theme(colors.border.semantic.info.1); + } + + &.active { + @apply bg-def-acc-4; + } + + &.inverted { + @apply bg-inv-1; + } + + &.has-action { + @apply pr-1.5; + + &:hover { + @apply bg-def-acc-3; + } + + &.inverted:hover { + @apply bg-inv-acc-3; + } + } + + & > .icon { + &:hover { + @apply cursor-pointer; + } + } +} diff --git a/pkgs/clan-app/ui/src/components/v2/Tag/Tag.stories.tsx b/pkgs/clan-app/ui/src/components/v2/Tag/Tag.stories.tsx new file mode 100644 index 000000000..29665e402 --- /dev/null +++ b/pkgs/clan-app/ui/src/components/v2/Tag/Tag.stories.tsx @@ -0,0 +1,47 @@ +import { Tag, TagProps } from "@/src/components/v2/Tag/Tag"; +import { Meta, type StoryContext, StoryObj } from "@kachurun/storybook-solid"; +import { expect, fn } from "storybook/test"; + +const meta: Meta = { + title: "Components/Tag", + component: Tag, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + label: "Label", + }, +}; + +export const WithAction: Story = { + args: { + ...Default.args, + action: { + icon: "Close", + onClick: fn(), + }, + }, + play: async ({ canvas, step, userEvent, args }: StoryContext) => { + await userEvent.click(canvas.getByRole("button")); + await expect(args.action.onClick).toHaveBeenCalled(); + }, +}; + +export const Inverted: Story = { + args: { + label: "Label", + inverted: true, + }, +}; + +export const InvertedWithAction: Story = { + args: { + ...WithAction.args, + inverted: true, + }, + play: WithAction.play, +}; diff --git a/pkgs/clan-app/ui/src/components/v2/Tag/Tag.tsx b/pkgs/clan-app/ui/src/components/v2/Tag/Tag.tsx new file mode 100644 index 000000000..fe8657480 --- /dev/null +++ b/pkgs/clan-app/ui/src/components/v2/Tag/Tag.tsx @@ -0,0 +1,54 @@ +import "./Tag.css"; + +import cx from "classnames"; +import { Typography } from "@/src/components/v2/Typography/Typography"; +import { createSignal, Show } from "solid-js"; +import Icon, { IconVariant } from "../Icon/Icon"; + +export interface TagAction { + icon: IconVariant; + onClick: () => void; +} + +export interface TagProps { + label: string; + action?: TagAction; + inverted?: boolean; +} + +export const Tag = (props: TagProps) => { + const inverted = () => props.inverted || false; + + const [isActive, setIsActive] = createSignal(false); + + const handleActionClick = () => { + setIsActive(true); + props.action?.onClick(); + setTimeout(() => setIsActive(false), 150); + }; + + return ( + + + {props.label} + + + + + + ); +}; From 36e2f25b5758fa70b7caf3bb6109c29cd1df740e Mon Sep 17 00:00:00 2001 From: Brian McGee Date: Fri, 20 Jun 2025 09:49:59 +0100 Subject: [PATCH 3/5] feat(ui): add TagGroup component Introduces a new `TagGroup` component for rendering grouped tags with optional inverted styling. --- .../src/components/v2/TagGroup/TagGroup.css | 3 ++ .../v2/TagGroup/TagGroup.stories.tsx | 43 +++++++++++++++++++ .../src/components/v2/TagGroup/TagGroup.tsx | 21 +++++++++ 3 files changed, 67 insertions(+) create mode 100644 pkgs/clan-app/ui/src/components/v2/TagGroup/TagGroup.css create mode 100644 pkgs/clan-app/ui/src/components/v2/TagGroup/TagGroup.stories.tsx create mode 100644 pkgs/clan-app/ui/src/components/v2/TagGroup/TagGroup.tsx diff --git a/pkgs/clan-app/ui/src/components/v2/TagGroup/TagGroup.css b/pkgs/clan-app/ui/src/components/v2/TagGroup/TagGroup.css new file mode 100644 index 000000000..59fef1eae --- /dev/null +++ b/pkgs/clan-app/ui/src/components/v2/TagGroup/TagGroup.css @@ -0,0 +1,3 @@ +div.tag-group { + @apply flex flex-wrap gap-x-1.5 gap-y-2; +} diff --git a/pkgs/clan-app/ui/src/components/v2/TagGroup/TagGroup.stories.tsx b/pkgs/clan-app/ui/src/components/v2/TagGroup/TagGroup.stories.tsx new file mode 100644 index 000000000..c0d660609 --- /dev/null +++ b/pkgs/clan-app/ui/src/components/v2/TagGroup/TagGroup.stories.tsx @@ -0,0 +1,43 @@ +import { TagGroup, TagGroupProps } from "@/src/components/v2/TagGroup/TagGroup"; +import { Meta, StoryObj } from "@kachurun/storybook-solid"; + +const meta: Meta = { + title: "Components/TagGroup", + component: TagGroup, + decorators: [ + (Story: StoryObj) => ( + /* for some reason w-x from tailwind was not working */ +
+ +
+ ), + ], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + labels: [ + "Tag 1", + "Tag 2", + "Tag 3", + "Tag 4", + "Tag 5", + "Tag 6", + "Tag 7", + "Tag 8", + "Tag 9", + "Tag 10", + ], + }, +}; + +export const Inverted: Story = { + args: { + ...Default.args, + inverted: true, + }, +}; diff --git a/pkgs/clan-app/ui/src/components/v2/TagGroup/TagGroup.tsx b/pkgs/clan-app/ui/src/components/v2/TagGroup/TagGroup.tsx new file mode 100644 index 000000000..e118ecca4 --- /dev/null +++ b/pkgs/clan-app/ui/src/components/v2/TagGroup/TagGroup.tsx @@ -0,0 +1,21 @@ +import "./TagGroup.css"; +import cx from "classnames"; +import { For } from "solid-js"; +import { Tag } from "@/src/components/v2/Tag/Tag"; + +export interface TagGroupProps { + labels: string[]; + inverted?: boolean; +} + +export const TagGroup = (props: TagGroupProps) => { + const inverted = () => props.inverted || false; + + return ( +
+ + {(label) => } + +
+ ); +}; From 7eb90acfc4a5bef8d19ddeb12d3cea6bab58ad2e Mon Sep 17 00:00:00 2001 From: Qubasa Date: Tue, 24 Jun 2025 10:35:53 +0200 Subject: [PATCH 4/5] clan-cli: Add missining propagation of host-key-check in machines.update --- pkgs/clan-cli/clan_cli/machines/update.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pkgs/clan-cli/clan_cli/machines/update.py b/pkgs/clan-cli/clan_cli/machines/update.py index e278d6f94..b04643017 100644 --- a/pkgs/clan-cli/clan_cli/machines/update.py +++ b/pkgs/clan-cli/clan_cli/machines/update.py @@ -280,7 +280,9 @@ def update_command(args: argparse.Namespace) -> None: address=args.target_host, ).override(host_key_check=host_key_check) else: - target_host = machine.target_host() + target_host = machine.target_host().override( + host_key_check=host_key_check + ) runtime.async_run( AsyncOpts( tid=machine.name, From b26aad3619f1bfb270199e9f62895e001564001e Mon Sep 17 00:00:00 2001 From: Qubasa Date: Tue, 24 Jun 2025 11:07:37 +0200 Subject: [PATCH 5/5] clan-cli: Move hardware.py to clan_lib/machines --- pkgs/clan-cli/clan_cli/machines/hardware.py | 151 +------------------ pkgs/clan-cli/clan_cli/machines/install.py | 141 +----------------- pkgs/clan-cli/clan_cli/ssh/results.py | 34 ----- pkgs/clan-cli/clan_lib/api/disk.py | 3 +- pkgs/clan-cli/clan_lib/machines/hardware.py | 152 ++++++++++++++++++++ pkgs/clan-cli/clan_lib/machines/install.py | 149 +++++++++++++++++++ 6 files changed, 308 insertions(+), 322 deletions(-) delete mode 100644 pkgs/clan-cli/clan_cli/ssh/results.py create mode 100644 pkgs/clan-cli/clan_lib/machines/hardware.py create mode 100644 pkgs/clan-cli/clan_lib/machines/install.py diff --git a/pkgs/clan-cli/clan_cli/machines/hardware.py b/pkgs/clan-cli/clan_cli/machines/hardware.py index 86da4538b..8204316cf 100644 --- a/pkgs/clan-cli/clan_cli/machines/hardware.py +++ b/pkgs/clan-cli/clan_cli/machines/hardware.py @@ -1,17 +1,12 @@ import argparse -import json import logging -from dataclasses import dataclass -from enum import Enum -from pathlib import Path -from clan_lib.api import API -from clan_lib.cmd import RunOpts, run -from clan_lib.dirs import specific_machine_dir -from clan_lib.errors import ClanCmdError, ClanError -from clan_lib.git import commit_file +from clan_lib.machines.hardware import ( + HardwareConfig, + HardwareGenerateOptions, + generate_machine_hardware_info, +) from clan_lib.machines.machines import Machine -from clan_lib.nix import nix_config, nix_eval from clan_lib.ssh.remote import Remote from clan_cli.completions import add_dynamic_completer, complete_machines @@ -21,142 +16,6 @@ from .types import machine_name_type log = logging.getLogger(__name__) -class HardwareConfig(Enum): - NIXOS_FACTER = "nixos-facter" - NIXOS_GENERATE_CONFIG = "nixos-generate-config" - NONE = "none" - - def config_path(self, machine: Machine) -> Path: - machine_dir = specific_machine_dir(machine) - if self == HardwareConfig.NIXOS_FACTER: - return machine_dir / "facter.json" - return machine_dir / "hardware-configuration.nix" - - @classmethod - def detect_type(cls: type["HardwareConfig"], machine: Machine) -> "HardwareConfig": - hardware_config = HardwareConfig.NIXOS_GENERATE_CONFIG.config_path(machine) - - if hardware_config.exists() and "throw" not in hardware_config.read_text(): - return HardwareConfig.NIXOS_GENERATE_CONFIG - - if HardwareConfig.NIXOS_FACTER.config_path(machine).exists(): - return HardwareConfig.NIXOS_FACTER - - return HardwareConfig.NONE - - -@API.register -def show_machine_hardware_config(machine: Machine) -> HardwareConfig: - """ - Show hardware information for a machine returns None if none exist. - """ - return HardwareConfig.detect_type(machine) - - -@API.register -def show_machine_hardware_platform(machine: Machine) -> str | None: - """ - Show hardware information for a machine returns None if none exist. - """ - config = nix_config() - system = config["system"] - cmd = nix_eval( - [ - f"{machine.flake}#clanInternals.machines.{system}.{machine.name}", - "--apply", - "machine: { inherit (machine.pkgs) system; }", - "--json", - ] - ) - proc = run(cmd, RunOpts(prefix=machine.name)) - res = proc.stdout.strip() - - host_platform = json.loads(res) - return host_platform.get("system", None) - - -@dataclass -class HardwareGenerateOptions: - machine: Machine - backend: HardwareConfig - password: str | None = None - - -@API.register -def generate_machine_hardware_info( - 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.ssh_control_master() 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}/{hw_file.name}: update hardware configuration", - ) - try: - show_machine_hardware_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 update_hardware_config_command(args: argparse.Namespace) -> None: host_key_check = args.host_key_check machine = Machine(flake=args.flake, name=args.machine) diff --git a/pkgs/clan-cli/clan_cli/machines/install.py b/pkgs/clan-cli/clan_cli/machines/install.py index 5943034e7..0aaba5c8f 100644 --- a/pkgs/clan-cli/clan_cli/machines/install.py +++ b/pkgs/clan-cli/clan_cli/machines/install.py @@ -1,17 +1,11 @@ import argparse import logging -import os import sys -from dataclasses import dataclass, field -from enum import Enum from pathlib import Path -from tempfile import TemporaryDirectory -from clan_lib.api import API -from clan_lib.cmd import Log, RunOpts, run from clan_lib.errors import ClanError +from clan_lib.machines.install import BuildOn, InstallOptions, install_machine from clan_lib.machines.machines import Machine -from clan_lib.nix import nix_shell from clan_lib.ssh.remote import Remote from clan_cli.completions import ( @@ -19,145 +13,12 @@ from clan_cli.completions import ( complete_machines, complete_target_host, ) -from clan_cli.facts.generate import generate_facts from clan_cli.machines.hardware import HardwareConfig from clan_cli.ssh.deploy_info import DeployInfo, find_reachable_host, ssh_command_parse -from clan_cli.vars.generate import generate_vars log = logging.getLogger(__name__) -class BuildOn(Enum): - AUTO = "auto" - LOCAL = "local" - REMOTE = "remote" - - -@dataclass -class InstallOptions: - machine: Machine - kexec: str | None = None - debug: bool = False - no_reboot: bool = False - phases: str | None = None - build_on: BuildOn | None = None - nix_options: list[str] = field(default_factory=list) - update_hardware_config: HardwareConfig = HardwareConfig.NONE - password: str | None = None - identity_file: Path | None = None - use_tor: bool = False - - -@API.register -def install_machine(opts: InstallOptions, target_host: Remote) -> None: - machine = opts.machine - - machine.debug(f"installing {machine.name}") - - generate_facts([machine]) - generate_vars([machine]) - - with ( - TemporaryDirectory(prefix="nixos-install-") as _base_directory, - ): - base_directory = Path(_base_directory).resolve() - activation_secrets = base_directory / "activation_secrets" - upload_dir = activation_secrets / machine.secrets_upload_directory.lstrip("/") - upload_dir.mkdir(parents=True) - machine.secret_facts_store.upload(upload_dir) - machine.secret_vars_store.populate_dir( - upload_dir, phases=["activation", "users", "services"] - ) - - partitioning_secrets = base_directory / "partitioning_secrets" - partitioning_secrets.mkdir(parents=True) - machine.secret_vars_store.populate_dir( - partitioning_secrets, phases=["partitioning"] - ) - - if opts.password: - os.environ["SSHPASS"] = opts.password - - cmd = [ - "nixos-anywhere", - "--flake", - f"{machine.flake}#{machine.name}", - "--extra-files", - str(activation_secrets), - ] - - for path in partitioning_secrets.rglob("*"): - if path.is_file(): - cmd.extend( - [ - "--disk-encryption-keys", - str( - "/run/partitioning-secrets" - / path.relative_to(partitioning_secrets) - ), - str(path), - ] - ) - - if opts.no_reboot: - cmd.append("--no-reboot") - - if opts.phases: - cmd += ["--phases", str(opts.phases)] - - if opts.update_hardware_config is not HardwareConfig.NONE: - cmd.extend( - [ - "--generate-hardware-config", - str(opts.update_hardware_config.value), - str(opts.update_hardware_config.config_path(machine)), - ] - ) - - if opts.password: - cmd += [ - "--env-password", - "--ssh-option", - "IdentitiesOnly=yes", - ] - - if opts.identity_file: - cmd += ["-i", str(opts.identity_file)] - - if opts.build_on: - cmd += ["--build-on", opts.build_on.value] - - if target_host.port: - cmd += ["--ssh-port", str(target_host.port)] - if opts.kexec: - cmd += ["--kexec", opts.kexec] - - if opts.debug: - cmd.append("--debug") - - # Add nix options to nixos-anywhere - cmd.extend(opts.nix_options) - - cmd.append(target_host.target) - if opts.use_tor: - # nix copy does not support tor socks proxy - # cmd.append("--ssh-option") - # cmd.append("ProxyCommand=nc -x 127.0.0.1:9050 -X 5 %h %p") - cmd = nix_shell( - [ - "nixos-anywhere", - "tor", - ], - ["torify", *cmd], - ) - else: - cmd = nix_shell( - ["nixos-anywhere"], - cmd, - ) - run(cmd, RunOpts(log=Log.BOTH, prefix=machine.name, needs_user_terminal=True)) - - def install_command(args: argparse.Namespace) -> None: try: # Only if the caller did not specify a target_host via args.target_host diff --git a/pkgs/clan-cli/clan_cli/ssh/results.py b/pkgs/clan-cli/clan_cli/ssh/results.py deleted file mode 100644 index 00b328d05..000000000 --- a/pkgs/clan-cli/clan_cli/ssh/results.py +++ /dev/null @@ -1,34 +0,0 @@ -from dataclasses import dataclass -from typing import Generic - -from clan_lib.errors import CmdOut -from clan_lib.ssh.remote import Remote - -from clan_cli.ssh import T - - -@dataclass -class HostResult(Generic[T]): - host: Remote - _result: T | Exception - - @property - def error(self) -> Exception | None: - """ - Returns an error if the command failed - """ - if isinstance(self._result, Exception): - return self._result - return None - - @property - def result(self) -> T: - """ - Unwrap the result - """ - if isinstance(self._result, Exception): - raise self._result - return self._result - - -Results = list[HostResult[CmdOut]] diff --git a/pkgs/clan-cli/clan_lib/api/disk.py b/pkgs/clan-cli/clan_lib/api/disk.py index cb7043dce..28a14befa 100644 --- a/pkgs/clan-cli/clan_lib/api/disk.py +++ b/pkgs/clan-cli/clan_lib/api/disk.py @@ -5,13 +5,12 @@ from dataclasses import dataclass from typing import Any, TypedDict from uuid import uuid4 -from clan_cli.machines.hardware import HardwareConfig, show_machine_hardware_config - from clan_lib.api import API from clan_lib.api.modules import Frontmatter, extract_frontmatter from clan_lib.dirs import TemplateType, clan_templates from clan_lib.errors import ClanError from clan_lib.git import commit_file +from clan_lib.machines.hardware import HardwareConfig, show_machine_hardware_config from clan_lib.machines.machines import Machine log = logging.getLogger(__name__) diff --git a/pkgs/clan-cli/clan_lib/machines/hardware.py b/pkgs/clan-cli/clan_lib/machines/hardware.py new file mode 100644 index 000000000..8324a7953 --- /dev/null +++ b/pkgs/clan-cli/clan_lib/machines/hardware.py @@ -0,0 +1,152 @@ +import json +import logging +from dataclasses import dataclass +from enum import Enum +from pathlib import Path + +from clan_lib.api import API +from clan_lib.cmd import RunOpts, run +from clan_lib.dirs import specific_machine_dir +from clan_lib.errors import ClanCmdError, ClanError +from clan_lib.git import commit_file +from clan_lib.machines.machines import Machine +from clan_lib.nix import nix_config, nix_eval +from clan_lib.ssh.remote import Remote + +log = logging.getLogger(__name__) + + +class HardwareConfig(Enum): + NIXOS_FACTER = "nixos-facter" + NIXOS_GENERATE_CONFIG = "nixos-generate-config" + NONE = "none" + + def config_path(self, machine: Machine) -> Path: + machine_dir = specific_machine_dir(machine) + if self == HardwareConfig.NIXOS_FACTER: + return machine_dir / "facter.json" + return machine_dir / "hardware-configuration.nix" + + @classmethod + def detect_type(cls: type["HardwareConfig"], machine: Machine) -> "HardwareConfig": + hardware_config = HardwareConfig.NIXOS_GENERATE_CONFIG.config_path(machine) + + if hardware_config.exists() and "throw" not in hardware_config.read_text(): + return HardwareConfig.NIXOS_GENERATE_CONFIG + + if HardwareConfig.NIXOS_FACTER.config_path(machine).exists(): + return HardwareConfig.NIXOS_FACTER + + return HardwareConfig.NONE + + +@API.register +def show_machine_hardware_config(machine: Machine) -> HardwareConfig: + """ + Show hardware information for a machine returns None if none exist. + """ + return HardwareConfig.detect_type(machine) + + +@API.register +def show_machine_hardware_platform(machine: Machine) -> str | None: + """ + Show hardware information for a machine returns None if none exist. + """ + config = nix_config() + system = config["system"] + cmd = nix_eval( + [ + f"{machine.flake}#clanInternals.machines.{system}.{machine.name}", + "--apply", + "machine: { inherit (machine.pkgs) system; }", + "--json", + ] + ) + proc = run(cmd, RunOpts(prefix=machine.name)) + res = proc.stdout.strip() + + host_platform = json.loads(res) + return host_platform.get("system", None) + + +@dataclass +class HardwareGenerateOptions: + machine: Machine + backend: HardwareConfig + password: str | None = None + + +@API.register +def generate_machine_hardware_info( + 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.ssh_control_master() 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}/{hw_file.name}: update hardware configuration", + ) + try: + show_machine_hardware_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 diff --git a/pkgs/clan-cli/clan_lib/machines/install.py b/pkgs/clan-cli/clan_lib/machines/install.py new file mode 100644 index 000000000..561fcd22d --- /dev/null +++ b/pkgs/clan-cli/clan_lib/machines/install.py @@ -0,0 +1,149 @@ +import logging +import os +from dataclasses import dataclass, field +from enum import Enum +from pathlib import Path +from tempfile import TemporaryDirectory + +from clan_cli.facts.generate import generate_facts +from clan_cli.machines.hardware import HardwareConfig +from clan_cli.vars.generate import generate_vars + +from clan_lib.api import API +from clan_lib.cmd import Log, RunOpts, run +from clan_lib.machines.machines import Machine +from clan_lib.nix import nix_shell +from clan_lib.ssh.remote import Remote + +log = logging.getLogger(__name__) + + +class BuildOn(Enum): + AUTO = "auto" + LOCAL = "local" + REMOTE = "remote" + + +@dataclass +class InstallOptions: + machine: Machine + kexec: str | None = None + debug: bool = False + no_reboot: bool = False + phases: str | None = None + build_on: BuildOn | None = None + nix_options: list[str] = field(default_factory=list) + update_hardware_config: HardwareConfig = HardwareConfig.NONE + password: str | None = None + identity_file: Path | None = None + use_tor: bool = False + + +@API.register +def install_machine(opts: InstallOptions, target_host: Remote) -> None: + machine = opts.machine + + machine.debug(f"installing {machine.name}") + + generate_facts([machine]) + generate_vars([machine]) + + with ( + TemporaryDirectory(prefix="nixos-install-") as _base_directory, + ): + base_directory = Path(_base_directory).resolve() + activation_secrets = base_directory / "activation_secrets" + upload_dir = activation_secrets / machine.secrets_upload_directory.lstrip("/") + upload_dir.mkdir(parents=True) + machine.secret_facts_store.upload(upload_dir) + machine.secret_vars_store.populate_dir( + upload_dir, phases=["activation", "users", "services"] + ) + + partitioning_secrets = base_directory / "partitioning_secrets" + partitioning_secrets.mkdir(parents=True) + machine.secret_vars_store.populate_dir( + partitioning_secrets, phases=["partitioning"] + ) + + if opts.password: + os.environ["SSHPASS"] = opts.password + + cmd = [ + "nixos-anywhere", + "--flake", + f"{machine.flake}#{machine.name}", + "--extra-files", + str(activation_secrets), + ] + + for path in partitioning_secrets.rglob("*"): + if path.is_file(): + cmd.extend( + [ + "--disk-encryption-keys", + str( + "/run/partitioning-secrets" + / path.relative_to(partitioning_secrets) + ), + str(path), + ] + ) + + if opts.no_reboot: + cmd.append("--no-reboot") + + if opts.phases: + cmd += ["--phases", str(opts.phases)] + + if opts.update_hardware_config is not HardwareConfig.NONE: + cmd.extend( + [ + "--generate-hardware-config", + str(opts.update_hardware_config.value), + str(opts.update_hardware_config.config_path(machine)), + ] + ) + + if opts.password: + cmd += [ + "--env-password", + "--ssh-option", + "IdentitiesOnly=yes", + ] + + if opts.identity_file: + cmd += ["-i", str(opts.identity_file)] + + if opts.build_on: + cmd += ["--build-on", opts.build_on.value] + + if target_host.port: + cmd += ["--ssh-port", str(target_host.port)] + if opts.kexec: + cmd += ["--kexec", opts.kexec] + + if opts.debug: + cmd.append("--debug") + + # Add nix options to nixos-anywhere + cmd.extend(opts.nix_options) + + cmd.append(target_host.target) + if opts.use_tor: + # nix copy does not support tor socks proxy + # cmd.append("--ssh-option") + # cmd.append("ProxyCommand=nc -x 127.0.0.1:9050 -X 5 %h %p") + cmd = nix_shell( + [ + "nixos-anywhere", + "tor", + ], + ["torify", *cmd], + ) + else: + cmd = nix_shell( + ["nixos-anywhere"], + cmd, + ) + run(cmd, RunOpts(log=Log.BOTH, prefix=machine.name, needs_user_terminal=True))