Line data Source code
1 : /*
2 : * Copyright (C) 2021 Red Hat, Inc.
3 : * SPDX-License-Identifier: LGPL-2.1-or-later
4 : */
5 :
6 19 : 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 19 : 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 19 : 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 19 : 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 :
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 19 : }
338 :
339 : interface KpatchStatusState {
340 : loaded: string[];
341 : installed: string[];
342 : changelog: string | null;
343 : }
344 :
345 19 : export class KpatchStatus extends React.Component<Record<string, never>, KpatchStatusState> {
346 19 : constructor(props: Record<string, never>) {
347 19 : super(props);
348 :
349 19 : this.state = {
350 19 : loaded: [],
351 19 : installed: [],
352 19 : changelog: null, // FIXME - load changelog
353 19 : };
354 19 : }
355 :
356 19 : componentDidMount() {
357 19 : 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 18 : .catch(() => true); // Ignore errors
376 19 : }
377 :
378 19 : render() {
379 19 : 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 19 : );
385 :
386 19 : 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 19 : );
392 :
393 19 : if (text.length > 0)
394 19 : return (
395 3 : <Split hasGutter>
396 3 : <SplitItem>
397 3 : <InfoIcon />
398 3 : </SplitItem>
399 3 : <SplitItem isFilled>
400 3 : <Stack>
401 3 : {text}
402 3 : </Stack>
403 3 : </SplitItem>
404 3 : </Split>
405 : );
406 :
407 19 : return null;
408 19 : }
409 19 : }
|