Merge pull request 'UI: fix installer workflow asking for sudo pw in background' (#2663) from hsjobeki/clan-core:hsjobeki-main into main

This commit is contained in:
clan-bot
2024-12-30 14:52:41 +00:00
5 changed files with 135 additions and 54 deletions

View File

@@ -4,6 +4,7 @@ import math
import os import os
import select import select
import shlex import shlex
import shutil
import signal import signal
import subprocess import subprocess
import threading import threading
@@ -271,6 +272,38 @@ class RunOpts:
needs_user_terminal: bool = False needs_user_terminal: bool = False
timeout: float = math.inf timeout: float = math.inf
shell: bool = False shell: bool = False
# Some commands require sudo
requires_root_perm: bool = False
# Ask for sudo password in a graphical way.
# This is needed for GUI applications
graphical_perm: bool = False
def cmd_with_root(cmd: list[str], graphical: bool = False) -> list[str]:
"""
This function returns a wrapped command that will be run with root permissions.
It will use sudo if graphical is False, otherwise it will use run0 or pkexec.
"""
if os.geteuid() == 0:
return cmd
# Decide permission handler
if graphical:
# TODO(mic92): figure out how to use run0
# if shutil.which("run0") is not None:
# perm_prefix = "run0"
if shutil.which("pkexec") is not None:
return ["pkexec", *cmd]
description = (
"pkexec is required to launch root commands with graphical permissions"
)
msg = "Missing graphical permission handler"
raise ClanError(msg, description=description)
if shutil.which("sudo") is None:
msg = "sudo is required to run this command as a non-root user"
raise ClanError(msg)
return ["sudo", *cmd]
def run( def run(
@@ -293,6 +326,9 @@ def run(
if options.stderr is None: if options.stderr is None:
options.stderr = async_ctx.stderr options.stderr = async_ctx.stderr
if options.requires_root_perm:
cmd = cmd_with_root(cmd, options.graphical_perm)
if options.input: if options.input:
if any(not ch.isprintable() for ch in options.input.decode("ascii", "replace")): if any(not ch.isprintable() for ch in options.input.decode("ascii", "replace")):
filtered_input = "<<binary_blob>>" filtered_input = "<<binary_blob>>"

View File

@@ -13,7 +13,7 @@ log = logging.getLogger(__name__)
@contextmanager @contextmanager
def pause_automounting( def pause_automounting(
devices: list[Path], machine: Machine devices: list[Path], machine: Machine, request_graphical: bool = False
) -> Generator[None, None, None]: ) -> 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
@@ -32,17 +32,32 @@ def pause_automounting(
raise ClanError(msg) raise ClanError(msg)
str_devs = [str(dev) for dev in devices] str_devs = [str(dev) for dev in devices]
cmd = ["sudo", str(inhibit_path), "enable", *str_devs] cmd = [str(inhibit_path), "enable", *str_devs]
result = run( result = run(
cmd, cmd,
RunOpts( RunOpts(
log=Log.BOTH, check=False, needs_user_terminal=True, prefix=machine.name log=Log.BOTH,
check=False,
needs_user_terminal=True,
prefix=machine.name,
requires_root_perm=True,
graphical_perm=request_graphical,
), ),
) )
if result.returncode != 0: if result.returncode != 0:
machine.error("Failed to inhibit automounting") machine.error("Failed to inhibit automounting")
yield None yield None
cmd = ["sudo", str(inhibit_path), "disable", *str_devs] cmd = [str(inhibit_path), "disable", *str_devs]
result = run(cmd, RunOpts(log=Log.BOTH, check=False, prefix=machine.name)) result = run(
cmd,
RunOpts(
log=Log.BOTH,
check=False,
prefix=machine.name,
requires_root_perm=True,
graphical_perm=request_graphical,
),
)
if result.returncode != 0: if result.returncode != 0:
machine.error("Failed to re-enable automounting") machine.error("Failed to re-enable automounting")

View File

@@ -2,14 +2,13 @@ import importlib
import json import json
import logging import logging
import os import os
import shutil
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from tempfile import TemporaryDirectory from tempfile import TemporaryDirectory
from typing import Any from typing import Any
from clan_cli.api import API from clan_cli.api import API
from clan_cli.cmd import Log, RunOpts, run from clan_cli.cmd import Log, RunOpts, cmd_with_root, 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.generate import generate_facts
from clan_cli.facts.secret_modules import SecretStoreBase from clan_cli.facts.secret_modules import SecretStoreBase
@@ -47,9 +46,10 @@ def flash_machine(
write_efi_boot_entries: bool, write_efi_boot_entries: bool,
debug: bool, debug: bool,
extra_args: list[str] | None = None, extra_args: list[str] | None = None,
graphical: bool = False,
) -> None: ) -> None:
devices = [Path(disk.device) for disk in disks] devices = [Path(disk.device) for disk in disks]
with pause_automounting(devices, machine): with pause_automounting(devices, machine, request_graphical=graphical):
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] = {}
@@ -108,10 +108,13 @@ def flash_machine(
disko_install = [] disko_install = []
if os.geteuid() != 0: if os.geteuid() != 0:
if shutil.which("sudo") is None: wrapper = " ".join(
msg = "sudo is required to run disko-install as a non-root user" [
raise ClanError(msg) "disko_install=$(command -v disko-install);",
wrapper = 'set -x; disko_install=$(command -v disko-install); exec sudo "$disko_install" "$@"' "exec",
*cmd_with_root(['"$disko_install" "$@"'], graphical=graphical),
]
)
disko_install.extend(["bash", "-c", wrapper]) disko_install.extend(["bash", "-c", wrapper])
disko_install.append("disko-install") disko_install.append("disko-install")
@@ -124,6 +127,8 @@ def flash_machine(
for disk in disks: for disk in disks:
disko_install.extend(["--disk", disk.name, disk.device]) disko_install.extend(["--disk", disk.name, disk.device])
log.info("Will flash disk %s: %s", disk.name, disk.device)
disko_install.extend(["--extra-files", str(local_dir), upload_dir]) disko_install.extend(["--extra-files", str(local_dir), upload_dir])
disko_install.extend(["--flake", str(machine.flake) + "#" + machine.name]) disko_install.extend(["--flake", str(machine.flake) + "#" + machine.name])
disko_install.extend(["--mode", str(mode)]) disko_install.extend(["--mode", str(mode)])

View File

@@ -1,5 +1,5 @@
import Dialog from "corvu/dialog"; import Dialog from "corvu/dialog";
import { createEffect, createSignal, JSX } from "solid-js"; import { createSignal, JSX } from "solid-js";
import { Button } from "../button"; import { Button } from "../button";
import Icon from "../icon"; import Icon from "../icon";
import cx from "classnames"; import cx from "classnames";
@@ -13,7 +13,7 @@ interface ModalProps {
export const Modal = (props: ModalProps) => { export const Modal = (props: ModalProps) => {
const [dragging, setDragging] = createSignal(false); const [dragging, setDragging] = createSignal(false);
const [startOffset, setStartOffset] = createSignal({ x: 0, y: 0 }); const [startOffset, setStartOffset] = createSignal({ x: 0, y: 0 });
// const [dialogStyle, setDialogStyle] = createSignal({ top: 100, left: 100 });
let dialogRef: HTMLDivElement; let dialogRef: HTMLDivElement;
const handleMouseDown = (e: MouseEvent) => { const handleMouseDown = (e: MouseEvent) => {
@@ -37,9 +37,6 @@ export const Modal = (props: ModalProps) => {
const handleMouseUp = () => setDragging(false); const handleMouseUp = () => setDragging(false);
createEffect(() => {
console.log("dialog open", props.open);
});
return ( return (
<Dialog open={props.open} trapFocus={true}> <Dialog open={props.open} trapFocus={true}>
<Dialog.Portal> <Dialog.Portal>

View File

@@ -14,6 +14,7 @@ import {
FieldValues, FieldValues,
setValue, setValue,
getValue, getValue,
getValues,
} from "@modular-forms/solid"; } from "@modular-forms/solid";
import { createQuery } from "@tanstack/solid-query"; import { createQuery } from "@tanstack/solid-query";
import { createEffect, createSignal, For, Show } from "solid-js"; import { createEffect, createSignal, For, Show } from "solid-js";
@@ -146,63 +147,89 @@ export const Flash = () => {
return dataTransfer.files; return dataTransfer.files;
}; };
const [confirmOpen, setConfirmOpen] = createSignal(false); const [confirmOpen, setConfirmOpen] = createSignal(false);
const [isFlashing, setFlashing] = createSignal(false);
const handleConfirm = async (values: FlashFormValues) => { const handleSubmit = (values: FlashFormValues) => {
setConfirmOpen(true); setConfirmOpen(true);
console.log("Submit:", values); };
toast.error("Not fully implemented yet"); const handleConfirm = async () => {
// Disabled for now. To prevent accidental flashing of local disks // Wait for the flash to complete
// User should confirm the disk to flash to const values = getValues(formStore) as FlashFormValues;
setFlashing(true);
// try { console.log("Confirmed flash:", values);
// await callApi("flash_machine", { try {
// machine: { await toast.promise(
// name: values.machine.devicePath, callApi("flash_machine", {
// flake: { machine: {
// loc: values.machine.flake, name: values.machine.devicePath,
// }, flake: {
// }, loc: values.machine.flake,
// mode: "format", },
// disks: [{ name: "main", device: values.disk }], },
// system_config: { mode: "format",
// language: values.language, disks: [{ name: "main", device: values.disk }],
// keymap: values.keymap, system_config: {
// ssh_keys_path: values.sshKeys.map((file) => file.name), language: values.language,
// }, keymap: values.keymap,
// dry_run: false, ssh_keys_path: values.sshKeys.map((file) => file.name),
// write_efi_boot_entries: false, },
// debug: false, dry_run: false,
// }); write_efi_boot_entries: false,
// } catch (error) { debug: false,
// toast.error(`Error could not flash disk: ${error}`); graphical: true,
// console.error("Error submitting form:", error); }),
// } {
error: (errors) => `Error flashing disk: ${errors}`,
loading: "Flashing ... This may take up to 15minutes.",
success: "Disk flashed successfully",
},
);
} catch (error) {
toast.error(`Error could not flash disk: ${error}`);
} finally {
setFlashing(false);
}
setConfirmOpen(false);
}; };
return ( return (
<> <>
<Header title="Flash installer" /> <Header title="Flash installer" />
<Modal <Modal
open={confirmOpen()} open={confirmOpen() || isFlashing()}
handleClose={() => setConfirmOpen(false)} handleClose={() => !isFlashing() && setConfirmOpen(false)}
title="Confirm" title="Confirm"
> >
<div class="flex flex-col gap-4 p-4"> <div class="flex flex-col gap-4 p-4">
<div class="flex justify-between rounded-sm border p-4 align-middle text-red-900 border-def-2"> <div class="flex flex-col justify-between rounded-sm border p-4 align-middle text-red-900 border-def-2">
<Typography <Typography
hierarchy="label" hierarchy="label"
weight="medium" weight="medium"
size="default" size="default"
class="flex-wrap break-words pr-4" class="flex-wrap break-words pr-4"
> >
Warning: All data on will be lost. Warning: All data will be lost.
<br /> </Typography>
<Typography
hierarchy="label"
weight="bold"
size="default"
class="flex-wrap break-words pr-4"
>
Selected disk: '{getValue(formStore, "disk")}' Selected disk: '{getValue(formStore, "disk")}'
</Typography> </Typography>
</div> </div>
<div class="flex w-full justify-between"> <div class="flex w-full justify-between">
<Button variant="light">Cancel</Button> <Button
<Button>Confirm</Button> disabled={isFlashing()}
variant="light"
onClick={() => setConfirmOpen(false)}
>
Cancel
</Button>
<Button disabled={isFlashing()} onClick={handleConfirm}>
Confirm
</Button>
</div> </div>
</div> </div>
</Modal> </Modal>
@@ -214,7 +241,7 @@ export const Flash = () => {
Will make bootstrapping new machines easier by providing secure remote Will make bootstrapping new machines easier by providing secure remote
connection to any machine when plugged in. connection to any machine when plugged in.
</Typography> </Typography>
<Form onSubmit={handleConfirm}> <Form onSubmit={handleSubmit}>
<div class="my-4"> <div class="my-4">
<Field name="sshKeys" type="File[]"> <Field name="sshKeys" type="File[]">
{(field, props) => ( {(field, props) => (
@@ -259,6 +286,7 @@ export const Flash = () => {
labelProps={{ labelProps={{
labelAction: ( labelAction: (
<Button <Button
disabled={isFlashing()}
class="ml-auto" class="ml-auto"
variant="ghost" variant="ghost"
size="s" size="s"