LCOV - code coverage report
Current view: top level - pkg/packagekit - kpatch.tsx Coverage Total Hit
Test: cockpit Lines: 12.3 % 310 38
Test Date: 2026-06-16 14:09:37

            Line data    Source code
       1              : /*
       2              :  * Copyright (C) 2021 Red Hat, Inc.
       3              :  * SPDX-License-Identifier: LGPL-2.1-or-later
       4              :  */
       5              : 
       6            1 : import React from "react";
       7              : import { Alert } from "@patternfly/react-core/dist/esm/components/Alert/index.js";
       8              : import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
       9              : import { Checkbox } from "@patternfly/react-core/dist/esm/components/Checkbox/index.js";
      10              : import { Form } from "@patternfly/react-core/dist/esm/components/Form/index.js";
      11              : import { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js";
      12              : import {
      13              :     Modal, ModalBody, ModalFooter, ModalHeader
      14              : } from '@patternfly/react-core/dist/esm/components/Modal/index.js';
      15              : import { Radio } from "@patternfly/react-core/dist/esm/components/Radio/index.js";
      16              : import { Popover } from "@patternfly/react-core/dist/esm/components/Popover/index.js";
      17              : import { Spinner } from "@patternfly/react-core/dist/esm/components/Spinner/index.js";
      18              : import { Split, SplitItem } from "@patternfly/react-core/dist/esm/layouts/Split/index.js";
      19              : import { Stack } from "@patternfly/react-core/dist/esm/layouts/Stack/index.js";
      20              : import { Content, ContentVariants } from "@patternfly/react-core/dist/esm/components/Content/index.js";
      21              : import { InfoIcon, InfoCircleIcon } from "@patternfly/react-icons";
      22              : import { Icon } from "@patternfly/react-core/dist/esm/components/Icon/index.js";
      23              : 
      24            1 : import cockpit from "cockpit";
      25              : import { proxy as serviceProxy } from "service";
      26              : import { install_dialog } from "cockpit-components-install-dialog.jsx";
      27              : import { getPackageManager } from "packagemanager";
      28              : import type { PackageManager } from "_internal/packagemanager-abstract";
      29              : 
      30            1 : const _ = cockpit.gettext;
      31              : 
      32              : type ServiceProxy = ReturnType<typeof serviceProxy> & cockpit.EventSource<{ changed(): void }>;
      33              : 
      34              : interface KpatchSettingsProps {
      35              :     privileged?: boolean;
      36              : }
      37              : 
      38              : interface KpatchSettingsState {
      39              :     loaded: boolean;
      40              :     auto: boolean | null;
      41              :     enabled: boolean | null;
      42              :     missing: string[];
      43              :     unavailable: string[];
      44              :     error: string;
      45              :     updating: boolean;
      46              :     showModal: boolean;
      47              :     applyCheckbox: boolean;
      48              :     justCurrent: boolean | null;
      49              :     kernelName: string;
      50              :     patchName: string | null;
      51              :     patchInstalled: boolean | null;
      52              :     patchUnavailable: boolean | null;
      53              :     packageManager: PackageManager | null;
      54              : }
      55              : 
      56            1 : export class KpatchSettings extends React.Component<KpatchSettingsProps, KpatchSettingsState> {
      57              :     kpatchService: ServiceProxy;
      58              : 
      59            0 :     constructor(props: KpatchSettingsProps) {
      60            0 :         super(props);
      61              : 
      62            0 :         this.state = {
      63            0 :             loaded: false,
      64            0 :             auto: null, // `dnf kpatch` is set to `auto`
      65            0 :             enabled: null, // kpatch.service is enabled
      66            0 :             missing: [], // missing packages from `kpatch`, `kpatch-dnf`
      67            0 :             unavailable: [], // unavailable packages from `kpatch`, `kpatch-dnf`
      68              : 
      69              :             // Modal states
      70            0 :             error: "",
      71            0 :             updating: false,
      72            0 :             showModal: false,
      73            0 :             applyCheckbox: false, // state of the checkbox
      74            0 :             justCurrent: null, // radio state
      75              : 
      76            0 :             kernelName: "", // uname -r
      77            0 :             patchName: null, // kpatch-patch name
      78            0 :             patchInstalled: null, // kpatch-patch installed
      79            0 :             patchUnavailable: null, // kpatch-patch available
      80              : 
      81            0 :             packageManager: null,
      82            0 :         };
      83              : 
      84            0 :         this.kpatchService = serviceProxy("kpatch") as ServiceProxy;
      85              : 
      86            0 :         this.checkSetup = this.checkSetup.bind(this);
      87            0 :         this.handleChange = this.handleChange.bind(this);
      88            0 :         this.onClose = this.onClose.bind(this);
      89            0 :         this.handleInstall = this.handleInstall.bind(this);
      90            0 :     }
      91              : 
      92              :     // Only current patches or also future ones
      93            0 :     current(enabled: boolean | null, installed: boolean | null, unavailable: boolean | null) {
      94            0 :         return enabled && (installed || unavailable);
      95            0 :     }
      96              : 
      97            0 :     async componentDidMount() {
      98            0 :         this.kpatchService.addEventListener('changed', () => {
      99            0 :             this.setState(state => {
     100            0 :                 const current = this.current(this.kpatchService.enabled, state.patchInstalled, state.patchUnavailable);
     101            0 :                 return ({
     102            0 :                     enabled: this.kpatchService.enabled,
     103            0 :                     justCurrent: current && !state.auto,
     104            0 :                     applyCheckbox: !!current,
     105            0 :                 });
     106            0 :             });
     107            0 :         });
     108              : 
     109            0 :         const packageManager = await getPackageManager();
     110            0 :         this.setState({ packageManager });
     111            0 :         packageManager.check_missing_packages(["kpatch", "kpatch-dnf"])
     112            0 :                 .then(d =>
     113            0 :                     this.checkSetup().then(() =>
     114            0 :                         this.setState({
     115            0 :                             loaded: true,
     116            0 :                             unavailable: d.unavailable_names || [],
     117            0 :                             missing: d.missing_names || [],
     118            0 :                         })
     119            0 :                     )
     120            0 :                 )
     121            0 :                 .catch((e: cockpit.BasicError) => console.log("Could not determine kpatch availability:", JSON.stringify(e)));
     122            0 :     }
     123              : 
     124            0 :     checkSetup() {
     125              :         // TODO - replace both with `dnf kpatch status` once https://github.com/dynup/kpatch-dnf/pull/8 lands
     126            0 :         const kpatch_promise = cockpit.file("/etc/dnf/plugins/kpatch.conf").read()
     127            0 :                 .then(data => {
     128            0 :                     if (data) {
     129            0 :                         const auto = /autoupdate\s*=\s*True/i.test(data);
     130            0 :                         this.setState((state, _) => {
     131            0 :                             const current = this.current(state.enabled, state.patchInstalled, state.patchUnavailable);
     132            0 :                             return ({
     133            0 :                                 auto: !!auto,
     134            0 :                                 justCurrent: current && !auto,
     135            0 :                                 applyCheckbox: !!current,
     136            0 :                             });
     137            0 :                         });
     138            0 :                     }
     139            0 :                 })
     140            0 :                 .catch(() => true); // Ignore errors, most likely just does not exist
     141              : 
     142            0 :         const uname_promise = cockpit.spawn(["uname", "-r"])
     143            0 :                 .then(data => {
     144            0 :                     const fields = data.split("-");
     145              :                     // if there's no release field, we don't have an official kernel
     146            0 :                     if (!fields[1])
     147            0 :                         return;
     148            0 :                     const kpp_kernel_version = fields[0].replaceAll(".", "_");
     149            0 :                     let release = fields[1].split(".");
     150            0 :                     release = release.slice(0, release.length - 2); // remove el8.x86_64
     151            0 :                     const kpp_kernel_release = release.join("_");
     152            0 :                     const patch_name = ["kpatch-patch", kpp_kernel_version, kpp_kernel_release].join("-");
     153            0 :                     cockpit.assert(this.state.packageManager, "packageManager not initialised");
     154            0 :                     return this.state.packageManager.check_missing_packages([patch_name])
     155            0 :                             .then(d =>
     156            0 :                                 this.setState((state, _) => {
     157            0 :                                     const installed = (d.unavailable_names || []).length === 0 && (d.missing_names || []).length === 0;
     158            0 :                                     const unavailable = (d.unavailable_names || []).length > 0;
     159            0 :                                     const current = this.current(state.enabled, installed, unavailable);
     160            0 :                                     return ({
     161            0 :                                         kernelName: data,
     162            0 :                                         patchName: patch_name,
     163            0 :                                         patchInstalled: installed,
     164            0 :                                         patchUnavailable: unavailable,
     165            0 :                                         justCurrent: current && !state.auto,
     166            0 :                                         applyCheckbox: !!current,
     167            0 :                                     });
     168            0 :                                 })
     169            0 :                             );
     170            0 :                 })
     171            0 :                 .catch(err => console.error("Could not determine kpatch packages:", JSON.stringify(err))); // not-covered: OS error
     172              : 
     173            0 :         return Promise.allSettled([kpatch_promise, uname_promise]);
     174            0 :     }
     175              : 
     176            0 :     handleInstall() {
     177            0 :         this.setState({ updating: true });
     178            0 :         install_dialog(this.state.missing)
     179            0 :                 .then(() => this.setState({ missing: [], updating: false }))
     180            0 :                 .catch(() => this.setState({ updating: false }));
     181            0 :     }
     182              : 
     183            0 :     onClose() {
     184            0 :         this.setState((state, _) => {
     185            0 :             const current = this.current(state.enabled, state.patchInstalled, state.patchUnavailable);
     186            0 :             return ({
     187            0 :                 justCurrent: current && !state.auto,
     188            0 :                 applyCheckbox: !!current,
     189            0 :                 showModal: false,
     190            0 :                 error: "",
     191            0 :             });
     192            0 :         });
     193            0 :     }
     194              : 
     195            0 :     handleChange() {
     196            0 :         this.setState({ updating: true });
     197              : 
     198            0 :         if (this.state.applyCheckbox) {
     199            0 :             let install;
     200            0 :             if (this.state.justCurrent) {
     201            0 :                 install = new Promise<void>((resolve, reject) => {
     202            0 :                     cockpit.spawn(["dnf", "-y", "kpatch", "manual"], { superuser: "require", err: "message" })
     203            0 :                             .then(() => {
     204            0 :                                 if (!this.state.patchUnavailable && !this.state.patchInstalled)
     205              :                                     // TODO - replace with `dnf kpatch install` once https://github.com/dynup/kpatch-dnf/pull/8 lands
     206            0 :                                     cockpit.spawn(["dnf", "-y", "install", this.state.patchName!], { superuser: "require", err: "message" }).then(() => resolve())
     207            0 :                                             .catch(reject);
     208              :                                 else
     209            0 :                                     resolve();
     210            0 :                             })
     211            0 :                             .catch(reject);
     212            0 :                 });
     213            0 :             } else {
     214            0 :                 install = cockpit.spawn(["dnf", "-y", "kpatch", "auto"], { superuser: "require", err: "message" });
     215            0 :             }
     216            0 :             install
     217            0 :                     .then(() =>
     218            0 :                         this.kpatchService.enable().then(() =>
     219            0 :                             this.kpatchService.start().then(() =>
     220            0 :                                 this.setState({ showModal: false, error: "" })
     221            0 :                             )
     222            0 :                         )
     223            0 :                     )
     224            0 :                     .catch((e: cockpit.BasicError) => this.setState({ error: e.toString() }))
     225            0 :                     .finally(() => this.checkSetup().then(() => this.setState({ updating: false })));
     226            0 :         } else {
     227            0 :             cockpit.spawn(["dnf", "-y", "kpatch", "manual"], { superuser: "require", err: "message" })
     228            0 :                     .then(() =>
     229            0 :                         this.kpatchService.disable().then(() =>
     230            0 :                             this.kpatchService.stop().then(() =>
     231            0 :                                 this.setState({ showModal: false, error: "" })
     232            0 :                             )
     233            0 :                         )
     234            0 :                     )
     235            0 :                     .catch((e: cockpit.BasicError) => this.setState({ error: e.toString() }))
     236            0 :                     .finally(() => this.checkSetup().then(() => this.setState({ updating: false })));
     237            0 :         }
     238            0 :     }
     239              : 
     240            0 :     render() {
     241            0 :         let state;
     242            0 :         let actionText = _("Edit");
     243            0 :         let action = () => this.setState({ showModal: true });
     244              : 
     245            0 :         if (this.state.loaded === false || this.state.patchName === null) {
     246              :             // Not yet recognized
     247            0 :             state = <Spinner size="md" />;
     248            0 :         } else if (this.state.unavailable.length > 0) {
     249            0 :             state = <Popover headerContent={ _("Unavailable packages") } bodyContent={ this.state.unavailable.join(", ") }>
     250            0 :                 <span>
     251            0 :                     { _("Not available") }
     252              :                     &nbsp;
     253            0 :                     <Icon status="info">
     254            0 :                         <InfoCircleIcon className="ct-info-circle" />
     255            0 :                     </Icon>
     256            0 :                 </span>
     257            0 :             </Popover>;
     258            0 :         } else if (this.state.missing.length > 0) {
     259            0 :             state = _("Not installed");
     260            0 :             actionText = _("Install");
     261            0 :             action = this.handleInstall;
     262            0 :         } else if (!this.state.enabled) {
     263            0 :             state = _("Disabled");
     264            0 :             actionText = _("Enable");
     265            0 :         } else {
     266            0 :             state = _("Enabled");
     267            0 :         }
     268              : 
     269            0 :         const kernel_name = this.state.kernelName ? " (" + this.state.kernelName + ")" : "";
     270            0 :         const error = this.state.error ? <Alert variant='danger' isInline title={this.state.error} /> : null;
     271              : 
     272            0 :         const body = <Form><Checkbox id="apply-kpatch"
     273            0 :                                isChecked={this.state.applyCheckbox}
     274            0 :                                label={_("Apply kernel live patches")}
     275            0 :                                onChange={(_event, checked) => this.setState({ applyCheckbox: checked })}
     276            0 :                                body={<>
     277            0 :                                    <Radio id="current-future"
     278            0 :                                           name="policy"
     279            0 :                                           label={_("for current and future kernels")}
     280            0 :                                           onChange={() => this.setState({ justCurrent: false })}
     281            0 :                                           isDisabled={!this.state.applyCheckbox}
     282            0 :                                           isChecked={!this.state.justCurrent} />
     283            0 :                                    <Radio id="current-only"
     284            0 :                                           name="policy"
     285            0 :                                           label={_("for current kernel only") + kernel_name}
     286            0 :                                           onChange={() => this.setState({ justCurrent: true })}
     287            0 :                                           isDisabled={!this.state.applyCheckbox}
     288            0 :                                           isChecked={!!this.state.justCurrent} />
     289            0 :                                </>}
     290            0 :         /></Form>;
     291              : 
     292            0 :         return (<>
     293            0 :             <div id="kpatch-settings">
     294            0 :                 <Flex alignItems={{ default: 'alignItemsCenter' }}>
     295            0 :                     <Flex grow={{ default: 'grow' }} alignItems={{ default: 'alignItemsBaseline' }}>
     296            0 :                         <FlexItem>
     297            0 :                             <b>{_("Kernel live patching")}</b>
     298            0 :                         </FlexItem>
     299            0 :                         <FlexItem>
     300            0 :                             {state}
     301            0 :                         </FlexItem>
     302            0 :                     </Flex>
     303            0 :                     <Flex>
     304            0 :                         <Button variant="secondary"
     305            0 :                                 size="sm"
     306            0 :                                 isDisabled={!this.props.privileged || this.state.updating || !this.state.loaded || this.state.unavailable.length > 0}
     307            0 :                                 onClick={action}>
     308            0 :                             {actionText}
     309            0 :                         </Button>
     310            0 :                     </Flex>
     311            0 :                 </Flex>
     312            0 :             </div>
     313            0 :             <Modal position="top" variant="small" id="kpatch-setup" isOpen={this.state.showModal}
     314            0 :                 onClose={ this.onClose }
     315              :             >
     316            0 :                 <ModalHeader title={_("Kernel live patch settings")} />
     317            0 :                 <ModalBody>
     318            0 :                     {error}
     319            0 :                     {body}
     320            0 :                 </ModalBody>
     321            0 :                 <ModalFooter>
     322            0 :                     <Button variant="primary"
     323            0 :                             isLoading={ this.state.updating }
     324            0 :                             isDisabled={ this.state.updating }
     325            0 :                             onClick={ this.handleChange }>
     326            0 :                         {_("Save")}
     327            0 :                     </Button>
     328            0 :                     <Button variant="link"
     329            0 :                             isDisabled={ this.state.updating }
     330            0 :                             onClick={ this.onClose }>
     331            0 :                         {_("Cancel")}
     332            0 :                     </Button>
     333            0 :                 </ModalFooter>
     334            0 :             </Modal>
     335            0 :         </>);
     336            0 :     }
     337            1 : }
     338              : 
     339              : interface KpatchStatusState {
     340              :     loaded: string[];
     341              :     installed: string[];
     342              :     changelog: string | null;
     343              : }
     344              : 
     345            1 : export class KpatchStatus extends React.Component<Record<string, never>, KpatchStatusState> {
     346            1 :     constructor(props: Record<string, never>) {
     347            1 :         super(props);
     348              : 
     349            1 :         this.state = {
     350            1 :             loaded: [],
     351            1 :             installed: [],
     352            1 :             changelog: null, // FIXME - load changelog
     353            1 :         };
     354            1 :     }
     355              : 
     356            1 :     componentDidMount() {
     357            1 :         cockpit.spawn(["kpatch", "list"], { superuser: "try", err: "ignore", environ: ["LC_MESSAGES=C"] })
     358            0 :                 .then(m => {
     359            0 :                     const parts = m.trim().split("\n\n");
     360            0 :                     if (parts.length !== 2 ||
     361            0 :                         !parts[0].startsWith("Loaded patch modules:") ||
     362            0 :                         !parts[1].startsWith("Installed patch modules:")) {
     363            0 :                         console.warn("Unexpected output from `kpatch list`", m);
     364            0 :                         return;
     365            0 :                     }
     366              : 
     367            0 :                     const loaded = parts[0].split("\n")
     368            0 :                             .slice(1)
     369            0 :                             .map(i => i.split(" ")[0]);
     370            0 :                     const installed = parts[1].split("\n")
     371            0 :                             .slice(1)
     372            0 :                             .map(i => i.split(" ")[0]);
     373            0 :                     this.setState({ loaded, installed });
     374            0 :                 })
     375            1 :                 .catch(() => true); // Ignore errors
     376            1 :     }
     377              : 
     378            1 :     render() {
     379            1 :         let text: React.ReactNode[] = [];
     380            0 :         text = this.state.loaded.map(i =>
     381            0 :             <Content key={i} component={ContentVariants.p}>
     382            0 :                 { cockpit.format(_("Kernel live patch $0 is active"), i) }
     383            0 :             </Content>
     384            1 :         );
     385              : 
     386            1 :         if (text.length === 0)
     387            0 :             text = this.state.installed.map(i =>
     388            0 :                 <Content key={i} component={ContentVariants.p}>
     389            0 :                     { cockpit.format(_("Kernel live patch $0 is installed"), i) }
     390            0 :                 </Content>
     391            1 :             );
     392              : 
     393            1 :         if (text.length > 0)
     394            1 :             return (
     395            1 :                 <Split hasGutter>
     396            1 :                     <SplitItem>
     397            1 :                         <InfoIcon />
     398            1 :                     </SplitItem>
     399            1 :                     <SplitItem isFilled>
     400            1 :                         <Stack>
     401            1 :                             {text}
     402            1 :                         </Stack>
     403            1 :                     </SplitItem>
     404            1 :                 </Split>
     405              :             );
     406              : 
     407            1 :         return null;
     408            1 :     }
     409            1 : }
        

Generated by: LCOV version 2.0-1