/* * Copyright (C) 2017 Red Hat, Inc. * SPDX-License-Identifier: LGPL-2.1-or-later */ import cockpit from "cockpit"; import React, { useState } from "react"; import { Alert } from "@patternfly/react-core/dist/esm/components/Alert/index.js"; import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js"; import { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js"; import { Form, FormGroup } from "@patternfly/react-core/dist/esm/components/Form/index.js"; import { FormSelect, FormSelectOption } from "@patternfly/react-core/dist/esm/components/FormSelect/index.js"; import { Modal, ModalBody, ModalFooter, ModalHeader } from '@patternfly/react-core/dist/esm/components/Modal/index.js'; import { Radio } from "@patternfly/react-core/dist/esm/components/Radio/index.js"; import { TimePicker } from "@patternfly/react-core/dist/esm/components/TimePicker/index.js"; import { install_dialog } from "cockpit-components-install-dialog.jsx"; import { useDialogs } from "dialogs.jsx"; import { debug } from "./utils"; const _ = cockpit.gettext; /** * Package manager specific implementations; PackageKit does not cover * automatic updates, so we have to implement dnf-automatic and * unattended-upgrades configuration ourselves */ class ImplBase { constructor() { this.supported = true; // false if system was customized in a way that we cannot parse this.enabled = null; // boolean this.type = null; // "all" or "security" this.day = null; // systemd.time(7) day of week (e. g. "mon"), or empty for daily this.time = null; // systemd.time(7) time (e. g. "06:00") or empty for "any time" this.installed = null; // boolean this.packageName = null; // name of the package providing automatic updates } // Init data members. Return a promise that resolves when done. async getConfig() { throw new Error("abstract method"); } // Update configuration for given non-null values, and update member variables on success; // return a promise that resolves when done, or fails when configuration writing fails async setConfig(enabled, type, day, time) { throw new Error("abstract method", enabled, type, day, time); } parseCalendar(spec) { // see systemd.time(7); we only support what we write, otherwise we treat it as custom config and "unsupported" const daysOfWeek = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]; const validTime = /^((|0|1)[0-9]|2[0-3]):[0-5][0-9]$/; const words = spec.trim().toLowerCase() .split(/\s+/); // check if we have a day of week if (daysOfWeek.indexOf(words[0]) >= 0) { this.day = words.shift(); } else if (words[0] === '*-*-*') { this.day = ""; // daily with "all matches" date specification words.shift(); } else { this.day = ""; // daily without date specification } // now there should only be a time left if (words.length === 1 && validTime.test(words[0])) this.time = words[0].replace(/^0+/, ""); else this.supported = false; } } class Dnf4Impl extends ImplBase { async getConfig() { this.packageName = "dnf-automatic"; try { // - dnf 4 has two ways to enable automatic updates: Either by enabling dnf-automatic-install.timer // or by setting "apply_updates = yes" in the config file and enabling dnf-automatic.timer // - the config file determines whether to apply security updates only // - by default this runs every day (OnUnitInactiveSec=1d), but the timer can be changed with a timer unit // drop-in, so get the last line const output = await cockpit.script( "set -e; if rpm -q " + this.packageName + " >/dev/null; then echo installed; fi; " + "if grep -q '^[ \\t]*upgrade_type[ \\t]*=[ \\t]*security' /etc/dnf/automatic.conf; then echo security; fi; " + "TIMER=dnf-automatic-install.timer; " + "if systemctl --quiet is-enabled dnf-automatic-install.timer 2>/dev/null; then echo enabled; " + "elif systemctl --quiet is-enabled dnf-automatic.timer 2>/dev/null && grep -q '^[ \t]*apply_updates[ \t]*=[ \t]*yes' " + " /etc/dnf/automatic.conf; then echo enabled; TIMER=dnf-automatic.timer; " + "fi; " + 'OUT=$(systemctl cat $TIMER 2>/dev/null || true); ' + 'echo "$OUT" | grep "^OnUnitInactiveSec= *[^ ]" | tail -n1; ' + 'echo "$OUT" | grep "^OnCalendar= *[^ ]" | tail -n1; ', [], { err: "message" }); this.installed = (output.indexOf("installed\n") >= 0); this.enabled = (output.indexOf("enabled\n") >= 0); this.type = (output.indexOf("security\n") >= 0) ? "security" : "all"; // if we have OnCalendar=, use that (we disable OnUnitInactiveSec= in our drop-in) const calIdx = output.indexOf("OnCalendar="); if (calIdx >= 0) { this.parseCalendar(output.substring(calIdx).split('\n')[0].split("=")[1]); } else { if (output.indexOf("InactiveSec=1d\n") >= 0) this.day = this.time = ""; else if (this.installed) this.supported = false; } debug(`dnf4 getConfig: supported ${this.supported}, enabled ${this.enabled}, type ${this.type}, day ${this.day}, time ${this.time}, installed ${this.installed}; raw response '${output}'`); } catch (error) { console.error("dnf4 getConfig failed:", error); this.supported = false; } } async setConfig(enabled, type, day, time) { const timerConf = "/etc/systemd/system/dnf-automatic-install.timer.d/time.conf"; let script = "set -e; "; if (type !== null) { const value = (type === "security") ? "security" : "default"; // normally upgrade_type = should already be in the file, so replace that line; // if it's not already present, append it to the file script += "sed -i '/\\bupgrade_type\\b[ \\t]*=/ { h; s/^.*$/upgrade_type = " + value + "/ }; " + "$ { x; /^$/ { s//upgrade_type = " + value + "/; H }; x }' /etc/dnf/automatic.conf; "; } // if we enable through Cockpit, make sure that starting the timer doesn't start the .service right away, // due to the packaged default OnBootSec=1h; just set a reasonable initial time which will trigger the code below if (enabled && !this.enabled && !this.time && !this.day) time = "6:00"; if (time !== null || day !== null) { if (day === "" && time === "") { // restore defaults script += "rm -f " + timerConf + "; "; } else { if (day === null) day = this.day; if (time === null) time = this.time; script += "mkdir -p /etc/systemd/system/dnf-automatic-install.timer.d; "; script += "printf '[Timer]\\nOnBootSec=\\nOnCalendar=" + day + " " + time + "\\n' > " + timerConf + "; "; script += "systemctl daemon-reload; "; } } if (enabled !== null) { const rebootConf = "/etc/systemd/system/dnf-automatic-install.service.d/autoreboot.conf"; script += "systemctl " + (enabled ? "enable" : "disable") + " --now dnf-automatic-install.timer; "; if (enabled) { /* dnf 4.15+ supports automatic reboots; check if the config option exists, and if so, change the default to "when-needed"; but be strict about the format, to avoid changing a customized setting */ script += "if grep '^[[:space:]]*reboot\\b' /etc/dnf/automatic.conf; then "; script += " sed -i 's/^reboot = never$/reboot = when-needed/' /etc/dnf/automatic.conf; "; // and drop the previous hack on upgrades */ script += " rm -f " + rebootConf + "; "; /* HACK for older dnf: enable automatic reboots after updating; dnf-automatic does not leave a log file behind for deciding whether it actually installed anything, so resort to grepping the journal for the last run (https://bugzilla.redhat.com/show_bug.cgi?id=1491190) */ script += "else "; script += " mkdir -p /etc/systemd/system/dnf-automatic-install.service.d; "; script += " printf '[Service]\\nExecStartPost=/bin/sh -ec \"" + "if systemctl status --no-pager --lines=100 dnf-automatic-install.service| grep -q ===========$$; then " + "shutdown -r +5 rebooting after applying package updates; fi\"\\n' > " + rebootConf + "; "; script += " systemctl daemon-reload; "; script += "fi"; } else { // also make sure that the legacy unit name is disabled; this can fail if the unit does not exist script += "systemctl disable --now dnf-automatic.timer 2>/dev/null || true; "; script += "rm -f " + rebootConf + "; "; } } debug(`dnf4 setConfig(${enabled}, "${type}", "${day}", "${time}"): script "${script}"`); try { await cockpit.script(script, [], { superuser: "require" }); debug("dnf4 setConfig: configuration updated successfully"); if (enabled !== null) this.enabled = enabled; if (type !== null) this.type = type; if (day !== null) this.day = day; if (time !== null) this.time = time; } catch (error) { console.error("dnf4 setConfig failed:", error.toString()); } } } class Dnf5Impl extends ImplBase { async getConfig() { this.packageName = "dnf5-plugin-automatic"; this.configFile = "/etc/dnf/dnf5-plugins/automatic.conf"; try { await cockpit.spawn(["rpm", "-q", this.packageName], { err: "ignore" }); this.installed = true; } catch (ex) { this.installed = false; debug("dnf5 getConfig: not installed:", ex); return; } // - dnf 5 only has a single timer dnf5-automatic.timer and a config file with // "apply_updates" (yes/no) and "upgrade_type" (default/security) // - by default this runs every day (OnCalendar) try { const output = await cockpit.script( "set -eu;" + "if grep -q '^[ \\t]*upgrade_type[ \\t]*=[ \\t]*security' " + this.configFile + "; then echo security; fi; " + "if systemctl --quiet is-enabled dnf5-automatic.timer && " + " grep -q '^[ \t]*apply_updates[ \t]*=[ \t]*yes' " + this.configFile + "; then echo enabled; fi; " + 'OUT=$(systemctl cat dnf5-automatic.timer || true); ' + 'echo "$OUT" | grep "^OnUnitInactiveSec= *[^ ]" | tail -n1; ' + 'echo "$OUT" | grep "^OnCalendar= *[^ ]" | tail -n1; ', [], { err: "message" }); this.enabled = (output.indexOf("enabled\n") >= 0); this.type = (output.indexOf("security\n") >= 0) ? "security" : "all"; // if we have OnCalendar=, use that (we disable OnUnitInactiveSec= in our drop-in) const calIdx = output.indexOf("OnCalendar="); if (calIdx >= 0) { this.parseCalendar(output.substring(calIdx).split('\n')[0].split("=")[1]); } else { if (output.indexOf("InactiveSec=1d\n") >= 0) this.day = this.time = ""; else if (this.installed) this.supported = false; } debug(`dnf5 getConfig: supported ${this.supported}, enabled ${this.enabled}, type ${this.type}, day ${this.day}, time ${this.time}, installed ${this.installed}; raw response '${output}'`); } catch (error) { console.error("dnf5 getConfig failed:", error); this.supported = false; } } async setConfig(enabled, type, day, time) { const timerConfD = "/etc/systemd/system/dnf5-automatic.timer.d"; const timerConf = timerConfD + "/time.conf"; let script = "set -e; "; // there's no default config file, admins are supposed to put their own settings into a new file const settings = []; if (type !== null) settings.push(["upgrade_type", (type === "security") ? "security" : "default"]); if (time !== null || day !== null) { if (day === "" && time === "") { // restore defaults script += "rm -f " + timerConf + "; "; } else { if (day === null) day = this.day; if (time === null) time = this.time; script += "mkdir -p " + timerConfD + "; "; script += "printf '[Timer]\\nOnBootSec=\\nOnCalendar=" + day + " " + time + "\\n' > " + timerConf + "; "; script += "systemctl daemon-reload; "; } } if (enabled !== null) { script += "systemctl " + (enabled ? "enable" : "disable") + " --now dnf5-automatic.timer; "; if (enabled) { settings.push(["apply_updates", "yes"]); settings.push(["reboot", "when-needed"]); } } debug(`dnf5 setConfig(${enabled}, "${type}", "${day}", "${time}"): script "${script}", settings ${settings}`); try { if (settings.length > 0) { // parse/update automatic.conf with the new settings; modify existing or append await cockpit.file(this.configFile, { superuser: "require" }).modify(content => { const lines = content ? content.split('\n') : []; settings.forEach(([key, value]) => { const idx = lines.findIndex(line => line.startsWith(key)); if (idx >= 0) lines[idx] = key + " = " + value; else // let's avoid context sensitive parsing/writing; multiple sections are ok lines.push(`[commands]\n${key} = ${value}`); }); return lines.join('\n'); }); } await cockpit.script(script, [], { superuser: "require" }); debug("dnf5 setConfig: configuration updated successfully"); if (enabled !== null) this.enabled = enabled; if (type !== null) this.type = type; if (day !== null) this.day = day; if (time !== null) this.time = time; } catch (error) { console.error("dnf5 setConfig failed:", error.toString()); } } } // Returns a promise for instantiating "backend"; this will never fail, if // automatic updates are not supported, backend will be null. export function getBackend(packagekit_backend, forceReinit) { if (!getBackend.promise || forceReinit) { debug("getBackend() called first time or forceReinit passed, initializing promise"); getBackend.promise = new Promise((resolve, reject) => { if (packagekit_backend === "dnf5") { const backend = new Dnf5Impl(); backend.getConfig().then(() => resolve(backend)); } else if (packagekit_backend === "dnf") { // we need to do this runtime check -- you can e.g. install dnf5 on Fedora 40, but it's not the "main" dnf cockpit.spawn(["dnf", "--version"], { err: "message" }) .then(version => { const backend = version.includes("dnf5") ? new Dnf5Impl() : new Dnf4Impl(); backend.getConfig().then(() => resolve(backend)); }) .catch(ex => { console.error("failed to run dnf --version:", ex); resolve(null); }); } else { // TODO: apt backend resolve(null); } }); } return getBackend.promise; } const AutoUpdatesDialog = ({ backend }) => { const Dialogs = useDialogs(); const [pending, setPending] = useState(false); const [enabled, setEnabled] = useState(backend.enabled); const [type, setType] = useState(backend.type); const [day, setDay] = useState(backend.day); const [time, setTime] = useState(backend.time && backend.time.padStart(5, "0")); function save(event) { setPending(true); backend.setConfig(enabled, type, day, time) .finally(Dialogs.close); if (event) event.preventDefault(); return false; } return (
{ setEnabled(false); setType(null) }} isDisabled={pending} label={_("No updates")} id="no-updates" name="type" /> { setEnabled(true); setType("security") }} isDisabled={pending} label={_("Security updates only")} id="security-updates" name="type" /> { setEnabled(true); setType("all") }} isDisabled={pending} label={_("All updates")} id="all-updates" name="type" /> {enabled && <> setDay(d === "everyday" ? "" : d) }> {_("at")} document.body} id="auto-update-time" isDisabled={pending} invalidFormatErrorMessage={_("Invalid time format")} onChange={(_, time) => setTime(time)} /> }
); }; export const AutoUpdates = ({ privileged, packagekit_backend, initial_backend }) => { const Dialogs = useDialogs(); const [backend, setBackend] = useState(initial_backend); if (!backend) return null; let state = null; if (!backend.enabled) state = _("Disabled"); if (!backend.installed) state = _("Not set up"); const days = { "": _("every day"), mon: _("every Monday"), tue: _("every Tuesday"), wed: _("every Wednesday"), thu: _("every Thursday"), fri: _("every Friday"), sat: _("every Saturday"), sun: _("every Sunday") }; let desc = null; if (backend.enabled && backend.supported) { const day = days[backend.day]; const time = backend.time; desc = backend.type === "security" ? cockpit.format(_("Security updates will be applied $0 at $1"), day, time) : cockpit.format(_("Updates will be applied $0 at $1"), day, time); } if (privileged && backend.installed && !backend.supported) return (
); return (
{_("Automatic updates")} {state} {desc}
); };