diff --git a/Makefile.am b/Makefile.am index 9decb9d71e68..01246471105c 100644 --- a/Makefile.am +++ b/Makefile.am @@ -150,6 +150,7 @@ WEBPACK_PACKAGES = \ networkmanager \ ostree \ pcp \ + packagekit \ playground \ realmd \ selinux \ diff --git a/doc/guide/Makefile-guide.am b/doc/guide/Makefile-guide.am index fc774aa625e5..c295bd1a61f3 100644 --- a/doc/guide/Makefile-guide.am +++ b/doc/guide/Makefile-guide.am @@ -29,6 +29,7 @@ GUIDE_INCLUDES = \ doc/guide/feature-machines.xml \ doc/guide/feature-networkmanager.xml \ doc/guide/feature-ostree.xml \ + doc/guide/feature-packagekit.xml \ doc/guide/feature-pcp.xml \ doc/guide/feature-realmd.xml \ doc/guide/feature-selinux.xml \ diff --git a/doc/guide/cockpit-guide.xml b/doc/guide/cockpit-guide.xml index 63b75c7b429e..234dfb319d86 100644 --- a/doc/guide/cockpit-guide.xml +++ b/doc/guide/cockpit-guide.xml @@ -49,6 +49,7 @@ + diff --git a/doc/guide/feature-packagekit.xml b/doc/guide/feature-packagekit.xml new file mode 100644 index 000000000000..9e735320ca46 --- /dev/null +++ b/doc/guide/feature-packagekit.xml @@ -0,0 +1,44 @@ + + + + Package Updates + + Cockpit uses the PackageKit + D-Bus API to get information about available package updates and to apply them, in an Operating System independent manner. + + To perform similar tasks from the command line, use the + pkcon command: + + +$ pkcon refresh + +$ pkcon get-updates +Available sudo-1.8.20p2-1.fc26.x86_64 (updates-testing) + Allows restricted root access for specified users +Available vim-filesystem-2:8.0.617-1.fc26.x86_64 (updates-testing) + VIM filesystem layout +Available vim-minimal-2:8.0.617-1.fc26.x86_64 (updates-testing) + A minimal version of the VIM editor + +$ pkcon get-update-detail sudo +Details about the update:6.x86_64 [fedora] + Package: sudo-1.8.20p2-1.fc26.x86_64 + Bugzilla: https://bugzilla.redhat.com/show_bug.cgi?id=1452941 + Update text: - update to 1.8.20p2 + - added sudo package to dnf/yum protected packages + +$ pkcon update +The following packages have to be updated: + sudo-1.8.20p2-1.fc26.x86_64 Allows restricted root access for specified users + vim-filesystem-2:8.0.617-1.fc26.x86_64 VIM filesystem layout + vim-minimal-2:8.0.617-1.fc26.x86_64 A minimal version of the VIM editor +Proceed with changes? [N/y] y +[...] + + +Of course you can also use your Operating System specific commands for that, such as + dnf updateinfo info on Fedora or + sudo apt upgrade on Debian. + + diff --git a/pkg/packagekit/index.html b/pkg/packagekit/index.html new file mode 100644 index 000000000000..637681b7297f --- /dev/null +++ b/pkg/packagekit/index.html @@ -0,0 +1,37 @@ + + + + + + Software Updates + + + + + + + + + + +
+ + + diff --git a/pkg/packagekit/manifest.json.in b/pkg/packagekit/manifest.json.in new file mode 100644 index 000000000000..03d4579bc428 --- /dev/null +++ b/pkg/packagekit/manifest.json.in @@ -0,0 +1,15 @@ +{ + "version": "@VERSION@", + "name": "updates", + "priority": 0, + "requires": { + "cockpit": "138" + }, + + "tools": { + "updates": { + "label": "Software Updates", + "path": "index.html" + } + } +} diff --git a/pkg/packagekit/updates.css b/pkg/packagekit/updates.css new file mode 100644 index 000000000000..ff67182f017c --- /dev/null +++ b/pkg/packagekit/updates.css @@ -0,0 +1,84 @@ +/* override default cockpit CSS, as this doesn't fit our table */ +table.listing-ct thead th:last-child { + text-align: left; +} + +tr.listing-ct-item td:last-child { + text-align: left; +} + +tr.listing-ct-item th { + vertical-align: top; +} + +tr.listing-ct-item td { + vertical-align: top; +} + +tr.listing-ct-item td.narrow { + max-width: 18ex; + word-wrap: break-word; +} + +tr.listing-ct-item td.changelog { + white-space: pre-wrap; +} + +tr.security { + background-color: #fbf0f0; +} + +.security-label { + color: darkred; +} + +.security-label-text { + color: darkred; + font-weight: 600; +} + +/* don't let the install progress bar get too wide */ +.progress { + max-width: 60rem; +} + +/* stolen from pkg/systemd/host.css */ +.content-header-extra { + background: #f5f5f5; + border-bottom: 1px solid #ddd; + padding: 10px 20px; + width: 100%; + position: fixed; + z-index: 900; + top: 0; +} + +.container-fluid { + margin-top: 5em; +} + +/* http://www.patternfly.org/pattern-library/communication/empty-state/ has a + * gray background which we don't want here */ + +.blank-slate-pf { + background: inherit; + border: none; +} + +/* Layout fix for tooltip arrows. + * + * Sometimes when there are non-integer coordinates involved + * somewhere, rounding seems to make it so that there is a visible one + * pixel gap between tooltip-inner and tooltip-arrow. We avoid that + * gap by letting the arrow and inner parts overlap by one pixel. + * + * This happens within the caption of the content listing. + */ + +.tooltip.top .tooltip-arrow { + bottom: 1px; +} + +.tooltip.bottom .tooltip-arrow { + top: 1px; +} diff --git a/pkg/packagekit/updates.jsx b/pkg/packagekit/updates.jsx new file mode 100644 index 000000000000..0f739fb39688 --- /dev/null +++ b/pkg/packagekit/updates.jsx @@ -0,0 +1,596 @@ +/* + * This file is part of Cockpit. + * + * Copyright (C) 2017 Red Hat, Inc. + * + * Cockpit is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation; either version 2.1 of the License, or + * (at your option) any later version. + * + * Cockpit is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Cockpit; If not, see . + */ + +var cockpit = require("cockpit"); +var React = require("react"); +var moment = require("moment"); +var Tooltip = require("cockpit-components-tooltip.jsx").Tooltip; +require("listing.less"); + +const _ = cockpit.gettext; + +// "available" heading is built dynamically +const STATE_HEADINGS = { + "loading": _("Loading available updates, please wait..."), + "locked": _("Some other program is currently using the package manager, please wait..."), + "uptodate": _("No updates pending"), + "applying": _("Applying updates"), + "updateError": _("Applying updates failed"), + "loadError": _("Loading available updates failed"), +} + +// see https://github.com/hughsie/PackageKit/blob/master/lib/packagekit-glib2/pk-enum.h +const PK_EXIT_ENUM_SUCCESS = 1; +const PK_EXIT_ENUM_FAILED = 2; +const PK_ROLE_ENUM_REFRESH_CACHE = 13; +const PK_ROLE_ENUM_UPDATE_PACKAGES = 22; +const PK_INFO_ENUM_SECURITY = 8; +const PK_STATUS_ENUM_WAIT = 1; +const PK_STATUS_ENUM_UPDATE = 10; +const PK_STATUS_ENUM_WAITING_FOR_LOCK = 30; + +const PK_STATUS_STRINGS = { + 8: _("Downloading"), + 9: _("Installing"), + 10: _("Updating"), + 11: _("Cleaning up"), + 14: _("Verifying"), +} + +var dbus_pk = cockpit.dbus("org.freedesktop.PackageKit", {superuser: "try", "track": true}); +var packageSummaries = {}; + +function pkTransaction() { + var dfd = cockpit.defer(); + + dbus_pk.call("/org/freedesktop/PackageKit", "org.freedesktop.PackageKit", "CreateTransaction", [], {timeout: 5000}) + .done(result => { + var transProxy = dbus_pk.proxy("org.freedesktop.PackageKit.Transaction", result[0]); + transProxy.wait(() => dfd.resolve(transProxy)); + }) + .fail(ex => dfd.reject(ex)); + + return dfd.promise(); +} + +// parse CVEs from an arbitrary text (changelog) and return URL array +function parseCVEs(text) { + if (!text) + return []; + + var cves = text.match(/CVE-\d{4}-\d+/g); + if (!cves) + return []; + return cves.map(n => "https://cve.mitre.org/cgi-bin/cvename.cgi?name=" + n); +} + +function deduplicate(list) { + var d = { }; + list.forEach(i => {if (i) d[i] = true}); + var result = Object.keys(d); + result.sort(); + return result; +} + +function commaJoin(list) { + return list.reduce((prev, cur) => [prev, ", ", cur]) +} + +function HeaderBar(props) { + var num_updates = Object.keys(props.updates).length; + var num_security = 0; + var state; + if (props.state == "available") { + state = cockpit.ngettext("$0 update", "$0 updates", num_updates); + for (var u in props.updates) + if (props.updates[u].security) + ++num_security; + if (num_security > 0) + state += cockpit.ngettext(", including $1 security fix", ", including $1 security fixes", num_security); + state = cockpit.format(state, num_updates, num_security); + } else { + state = STATE_HEADINGS[props.state]; + } + + var lastChecked; + if (props.timeSinceRefresh) { + lastChecked = ( + + {cockpit.format(_("Last checked: $0 ago"), moment.duration(props.timeSinceRefresh * 1000).humanize())} + + ); + } + var refreshButton; + if (props.state == "uptodate" || props.state == "available") + refreshButton = ; + + return ( +
+ + + + + +
{state}{lastChecked} {refreshButton}
+
+ ); +} + +function UpdateItem(props) { + const info = props.info; + var bugs = null; + var security_info = null; + + if (info.bug_urls && info.bug_urls.length) { + // we assume a bug URL ends with a number; if not, show the complete URL + bugs = commaJoin(info.bug_urls.map(u => {u.match(/[0-9]+$/) || u})); + } + + if (info.security) { + security_info = ( +

+ + {_("Security Update") + (info.cve_urls.length ? ": " : "")} + {commaJoin(info.cve_urls.map(u => {u.match(/[^/=]+$/)}))} +

+ ); + } + + return ( + + + {commaJoin(props.pkgNames.map(n => ({n})))} + {info.version} + {bugs} + {security_info}{info.description} + + + ); +} + +function UpdatesList(props) { + var updates = []; + + // PackageKit doesn"t expose source package names, so group packages with the same version and changelog + // create a reverse version+changes → [id] map on iteration + var sameUpdate = {}; + var packageNames = {}; + Object.keys(props.updates).forEach(id => { + let u = props.updates[id]; + // did we already see the same version and description? then merge + let hash = u.version + u.description; + let seenId = sameUpdate[hash]; + if (seenId) { + packageNames[seenId].push(u.name); + } else { + // this is a new update + sameUpdate[hash] = id; + packageNames[id] = [u.name]; + updates.push(id); + } + }); + + // sort security first + updates.sort((a, b) => { + if (props.updates[a].security && !props.updates[b].security) + return -1; + if (!props.updates[a].security && props.updates[b].security) + return 1; + return a.localeCompare(b); + }); + + return ( + + + + + + + + + + {updates.map(id => )} +
{_("Name")}{_("Version")}{_("Bugs")}{_("Details")}
+ ); +} + +class ApplyUpdates extends React.Component { + constructor() { + super(); + this.state = {percentage: null, timeRemaining: null, curStatus: null, curPackage: null}; + } + + componentDidMount() { + var transProxy = this.props.transaction; + + transProxy.addEventListener("Package", (event, info, packageId) => { + var pfields = packageId.split(";"); + // info: see PK_STATUS_* at https://github.com/hughsie/PackageKit/blob/master/lib/packagekit-glib2/pk-enum.h + this.setState({curPackage: pfields[0] + " " + pfields[1], + curStatus: info, + percentage: transProxy.Percentage <= 100 ? transProxy.Percentage : null, + timeRemaining: transProxy.RemainingTime > 0 ? transProxy.RemainingTime : null}); + }); + } + + render() { + var action; + + if (this.state.curPackage) + action = ( + + {PK_STATUS_STRINGS[this.state.curStatus || PK_STATUS_ENUM_UPDATE] || PK_STATUS_STRINGS[PK_STATUS_ENUM_UPDATE]} +  {this.state.curPackage} + + ); + else + action = _("Initializing..."); + + var progressBar; + if (this.state.percentage !== null) + progressBar = ( +
+
+ {this.state.timeRemaining !== null ? {moment.duration(this.state.timeRemaining * 1000).humanize()} : null} +
+
+ ); + + return ( +
+
+
+ {action} +
+ {progressBar} +
+ ); + } +} + +class OsUpdates extends React.Component { + constructor() { + super(); + this.state = {state: "loading", errorMessages: [], updates: {}, haveSecurity: false, timeSinceRefresh: null, + loadPercent: null, waiting: false, cockpitUpdate: false}; + this.handleLoadError = this.handleLoadError.bind(this); + this.handleRefresh = this.handleRefresh.bind(this); + } + + componentDidMount() { + // check if there is an upgrade in progress already; if so, switch to "applying" state right away + dbus_pk.call("/org/freedesktop/PackageKit", "org.freedesktop.PackageKit", "GetTransactionList", [], {timeout: 5000}) + .done(result => { + let transactions = result[0]; + let promises = transactions.map(transObj => dbus_pk.call( + transObj, "org.freedesktop.DBus.Properties", "Get", + ["org.freedesktop.PackageKit.Transaction", "Role"], {timeout: 5000})); + + cockpit.all(promises) + .done(roles => { + // any transaction with UPDATE_PACKAGES role? + for (let idx = 0; idx < roles.length; ++idx) { + if (roles[idx].v == PK_ROLE_ENUM_UPDATE_PACKAGES) { + var transProxy = dbus_pk.proxy("org.freedesktop.PackageKit.Transaction", transactions[idx]); + transProxy.wait(() => this.watchUpdates(transProxy)); + return; + } + } + + // no running updates found, proceed to showing available updates + this.loadUpdates(); + }) + .fail(ex => { + console.warn("GetTransactionList: failed to read PackageKit transaction roles:", ex.message); + // be robust, try to continue with loading updates anyway + this.loadUpdates(); + }); + + }); + + dbus_pk.addEventListener("close", (event, ex) => { + console.log("close:", event, ex); + var err; + if (ex.problem == "not-found") + err = _("PackageKit is not installed") + else + err = _("PackageKit crashed"); + if (this.state.state == "loading") { + this.handleLoadError(err); + } else if (this.state.state == "applying") { + this.state.errorMessages.push(err); + this.setState({state: "updateError"}); + } else { + console.log("PackageKit went away in state", this.state.state); + } + }); + } + + handleLoadError(ex) { + this.state.errorMessages.push(ex.message || ex); + this.setState({state: "loadError"}); + } + + formatDescription(text) { + // on Debian they start with "== version ==" which is redundant; we + // don"t want Markdown headings in the table + return text.trim().replace(/^== .* ==\n/, "").trim(); + } + + loadUpdateDetails(pkg_ids) { + pkTransaction() + .done(transProxy => { + transProxy.addEventListener("UpdateDetail", (event, packageId, updates, obsoletes, vendor_urls, + bug_urls, cve_urls, restart, update_text, changelog + /* state, issued, updated */) => { + let u = this.state.updates[packageId]; + u.vendor_urls = vendor_urls; + u.bug_urls = deduplicate(bug_urls); + u.description = this.formatDescription(update_text || changelog); + // many backends don"t support this; parse CVEs from description as a fallback + u.cve_urls = deduplicate(cve_urls && cve_urls.length > 0 ? cve_urls : parseCVEs(u.description)); + if (u.cve_urls && u.cve_urls.length > 0) + u.security = true; + // u.restart = restart; // broken (always "1") at least in Fedora + + this.setState({updates: this.state.updates, haveSecurity: this.state.haveSecurity || u.security}); + }); + + transProxy.addEventListener("Finished", () => this.setState({state: "available"})); + + transProxy.addEventListener("ErrorCode", (event, code, details) => { + console.warn("UpdateDetail error:", code, details); + // still show available updates, with reduced detail + this.setState({state: "available"}); + }); + + transProxy.GetUpdateDetail(pkg_ids) + .fail(ex => { + console.warn("GetUpdateDetail failed:", ex); + // still show available updates, with reduced detail + this.setState({state: "available"}); + }); + }); + } + + loadUpdates() { + var updates = {}; + var cockpitUpdate = false; + + pkTransaction() + .done(transProxy => { + transProxy.addEventListener("Package", (event, info, packageId, _summary) => { + let id_fields = packageId.split(";"); + packageSummaries[id_fields[0]] = _summary; + updates[packageId] = {name: id_fields[0], version: id_fields[1], security: info == PK_INFO_ENUM_SECURITY}; + if (id_fields[0] == "cockpit-ws") + cockpitUpdate = true; + }); + + transProxy.addEventListener("ErrorCode", (event, code, details) => { + this.state.errorMessages.push(details); + this.setState({state: "loadError"}); + }); + + transProxy.addEventListener("changed", (event, data) => { + if ("Status" in data) { + let waiting = (data.Status == PK_STATUS_ENUM_WAIT || data.Status == PK_STATUS_ENUM_WAITING_FOR_LOCK); + if (waiting != this.state.waiting) { + // to avoid flicker, we only switch to "locked" after 1s, as we will get a WAIT state + // even if the package db is unlocked + if (waiting) { + this.setState({waiting: true}); + window.setTimeout(() => {!this.state.waiting || this.setState({state: "locked"})}, 1000); + } else { + this.setState({state: "loading", waiting: false}); + } + } + } + }); + + // when GetUpdates() finished, get the details for all packages + transProxy.addEventListener("Finished", () => { + var pkg_ids = Object.keys(updates); + if (pkg_ids.length) { + this.setState({updates: updates, cockpitUpdate: cockpitUpdate}); + this.loadUpdateDetails(pkg_ids); + } else { + this.setState({state: "uptodate"}); + } + }); + + // read available updates; causes emission of Package and Error, doesn"t return anything by itself + transProxy.GetUpdates(0) + .fail(this.handleLoadError); + }) + .fail(ex => this.handleLoadError((ex.problem == "not-found") ? _("PackageKit is not installed") : ex)); + + dbus_pk.call("/org/freedesktop/PackageKit", "org.freedesktop.PackageKit", "GetTimeSinceAction", + [PK_ROLE_ENUM_REFRESH_CACHE], {timeout: 5000}) + .done(seconds => { + const ONE_DAY = 86400; + const TEN_YEARS = 10 * 365 * ONE_DAY; + + // return type is "u", but returns -1 if not supported; so ignore implausibly high values (> 10 years) + if (seconds > TEN_YEARS) + return; + + this.setState({timeSinceRefresh: seconds}); + + // automatically trigger refresh for ≥ 1 day + if (seconds >= ONE_DAY) + this.handleRefresh(); + + }) + .fail(ex => console.warn("failed to get time of last refresh: " + ex.message)); + } + + watchUpdates(transProxy) { + this.setState({state: "applying", applyTransaction: transProxy}); + + transProxy.addEventListener("ErrorCode", (event, code, details) => this.state.errorMessages.push(details)); + transProxy.addEventListener("Finished", (event, exit) => { + if (exit == PK_EXIT_ENUM_SUCCESS) { + this.setState({state: "loading", haveSecurity: false, loadPercent: null}); + this.loadUpdates(); + } else { + // normally we get FAILED here with ErrorCodes; handle unexpected errors to allow for some debugging + if (exit != PK_EXIT_ENUM_FAILED) + this.state.errorMessages.push(cockpit.format(_("PackageKit reported error code {0}"), exit)); + this.setState({state: "updateError"}); + } + }); + + // not working/being used in at least Fedora + transProxy.addEventListener("RequireRestart", (event, type, packageId) => { + console.log("update RequireRestart", type, packageId); + }); + } + + applyUpdates(securityOnly) { + pkTransaction() + .done(transProxy => { + this.watchUpdates(transProxy); + + var ids = Object.keys(this.state.updates); + if (securityOnly) + ids = ids.filter(id => this.state.updates[id].security); + + // returns immediately without value + transProxy.UpdatePackages(0, ids) + .fail(ex => { + this.state.errorMessages.push(ex.message); + this.setState({state: "updateError"}); + }); + }) + // this Should Not Fail™, so don"t bother about the slightly mislabeled state here + .fail(this.handleLoadError); + } + + renderContent() { + switch (this.state.state) { + case "loading": + case "locked": + if (this.state.loadPercent) + return ( +
+
+
+
) + else + return
; + + case "available": + return ( +
+ + + + + +

{_("Available Packages")}

+ { this.state.haveSecurity + ? + : null + } +     + +
+ { this.state.cockpitUpdate + ?
+ + + {_("Cockpit itself will be updated.")} +   + {_("When you get disconnected, the updates will continue in the background. You can reconnect and resume watching the update progress.")} + +
+ : null + } + +
+ ); + + case "loadError": + case "updateError": + return this.state.errorMessages.map(m =>
{m}
); + + case "applying": + return + + case "uptodate": + return ( +
+
+ +
+

{_("System is up to date")}

+
); + + default: + return null; + } + } + + handleRefresh() { + this.setState({state: "loading", loadPercent: null}); + pkTransaction() + .done(transProxy => { + transProxy.addEventListener("ErrorCode", (event, code, details) => this.handleLoadError(details)); + transProxy.addEventListener("Finished", (event, exit) => { + if (exit == PK_EXIT_ENUM_SUCCESS) + this.loadUpdates(); + else + this.setState({state: "loadError"}); + }); + + transProxy.addEventListener("changed", (event, data) => { + if ("Percentage" in data && data.Percentage <= 100) + this.setState({loadPercent: data.Percentage}); + }); + + transProxy.RefreshCache(true) + .fail(this.handleLoadError); + }) + .fail(this.handleLoadError); + } + + render() { + return ( +
+ +
+ {this.renderContent()} +
+
+ ); + } +} + +document.addEventListener("DOMContentLoaded", () => { + document.title = cockpit.gettext(document.title); + React.render(, document.getElementById("app")); +}); diff --git a/test/verify/check-packagekit b/test/verify/check-packagekit new file mode 100755 index 000000000000..95c27877edb2 --- /dev/null +++ b/test/verify/check-packagekit @@ -0,0 +1,469 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# This file is part of Cockpit. +# +# Copyright (C) 2017 Red Hat, Inc. +# +# Cockpit is free software; you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 2.1 of the License, or +# (at your option) any later version. +# +# Cockpit is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with Cockpit; If not, see . + +import parent +from testlib import * + +@skipImage("Image uses OSTree", "continuous-atomic", "fedora-atomic", "rhel-atomic") +@skipImage("PackageKit crashes, https://launchpad.net/bugs/1689820", "ubuntu-1604") +class TestUpdates(MachineCase): + def setUp(self): + MachineCase.setUp(self) + + self.isApt = "debian" in self.machine.image or "ubuntu" in self.machine.image + + # disable all existing repositories to avoid hitting the network + if self.isApt: + self.machine.execute("rm -f /etc/apt/sources.list.d/*; echo > /etc/apt/sources.list; apt-get update") + else: + self.machine.execute("rm -f /etc/yum.repos.d/* /var/cache/yum/*") + + # have PackageKit start from a clean slate + self.machine.execute("systemctl stop packagekit; rm -rf /var/cache/PackageKit") + + if self.machine.image in ["ubuntu-1604", "debian-stable"]: + # old PackageKit+NM on Debian/Ubuntu misdetect online status with ifupdown; work around + # https://launchpad.net/bugs/1694438; this doesn't affect pure ifupdown systems (servers) and + # pure NM systems (desktops) + self.machine.execute("sed -i '/managed=/ s/false/true/' /etc/NetworkManager/NetworkManager.conf; systemctl restart NetworkManager") + + self.updateInfo = {} + + def testBasic(self): + # no security updates, no changelogs + b = self.browser + m = self.machine + + m.start_cockpit() + b.login_and_go("/updates") + + # no repositories at all, thus no updates + b.wait_present(".content-header-extra td button") + b.wait_in_text("#state", "No updates pending") + # empty state visible in main area + b.wait_present(".container-fluid div.blank-slate-pf") + + # create two updates + self.createPackage("vanilla", "1.0", "1", install=True) + self.createPackage("vanilla", "1.0", "2") + self.createPackage("chocolate", "2.0", "1", install=True) + self.createPackage("chocolate", "2.0", "2") + self.enableRepo() + + # check again + b.wait_in_text(".content-header-extra td button", "Check for updates") + b.click(".content-header-extra td button") + + b.wait_present(".container-fluid h2") + b.wait_in_text(".container-fluid h2", "Available Packages") + self.assertEqual(b.text("#state"), "2 updates") + + b.wait_present("table.listing-ct") + b.wait_in_text("table.listing-ct", "vanilla") + + # chocolate update to 2.0-2 + self.assertEqual(b.text("#app .listing-ct tbody:nth-of-type(1) th span"), "chocolate") + self.assertEqual(b.text("#app .listing-ct tbody:nth-of-type(1) th .tooltip-inner"), "dummy chocolate") + self.assertEqual(b.text("#app .listing-ct tbody:nth-of-type(1) td:nth-of-type(1)"), "2.0-2") + self.assertEqual(b.text("#app .listing-ct tbody:nth-of-type(1) td:nth-of-type(2)"), "") # no bugs + + # vanilla update to 1.0-2 + self.assertEqual(b.text("#app .listing-ct tbody:nth-of-type(2) th span"), "vanilla") + self.assertEqual(b.text("#app .listing-ct tbody:nth-of-type(2) th .tooltip-inner"), "dummy vanilla") + self.assertEqual(b.text("#app .listing-ct tbody:nth-of-type(2) td:nth-of-type(1)"), "1.0-2") + self.assertEqual(b.text("#app .listing-ct tbody:nth-of-type(2) td:nth-of-type(2)"), "") # no bugs + + # old versions are still installed + m.execute("test -f /stamp-vanilla-1.0-1 && test -f /stamp-chocolate-2.0-1") + + # should only have one button (no security updates) + self.assertEqual(b.text("#app .container-fluid button"), "Install all updates") + b.click("#app .container-fluid button") + + b.wait_in_text("#state", "Applying updates") + b.wait_present("#app div.progress-bar") + + # should have succeeded + b.wait_in_text("#state", "No updates pending") + # empty state visible in main area + b.wait_present(".container-fluid div.blank-slate-pf") + + # new versions are now installed + m.execute("test -f /stamp-vanilla-1.0-2 && test -f /stamp-chocolate-2.0-2") + + @skipImage("apt on Debian 8 does not yet support custom changelog servers", "debian-stable") + def testInfoSecurity(self): + b = self.browser + m = self.machine + + # just changelog + self.createPackage("norefs-bin", "1", "1", install=True) + self.createPackage("norefs-bin", "2", "1", severity="enhancement", changes="Now 10% more unicorns") + # binary from same source + self.createPackage("norefs-doc", "1", "1", install=True) + self.createPackage("norefs-doc", "2", "1", severity="enhancement", changes="Now 10% more unicorns") + # bug fixes + self.createPackage("buggy", "2", "1", install=True) + self.createPackage("buggy", "2", "2", changes="Fixit", bugs=[123, 456]) + # security fix with proper CVE list and severity + self.createPackage("secdeclare", "3", "4.a1", install=True) + self.createPackage("secdeclare", "3", "4.b1", severity="security", + changes="stop kittens from dying", cves=['CVE-2016-0001']) + # security fix with parsing from changes + self.createPackage("secparse", "4", "1", install=True) + self.createPackage("secparse", "4", "2", changes="Fix CVE-2017-0001 and CVE-2017-0002.") + + self.enableRepo() + m.execute("pkcon refresh") + + m.start_cockpit() + b.login_and_go("/updates") + b.wait_present(".container-fluid h2") + b.wait_in_text(".container-fluid h2", "Available Packages") + self.assertEqual(b.text("#state"), "5 updates, including 2 security fixes") + + b.wait_present("table.listing-ct") + b.wait_in_text("table.listing-ct", "secparse") + + # security updates should get sorted on top and then alphabetically, so start with "secdeclare" + self.assertEqual(b.text("#app .listing-ct tbody:nth-of-type(1) th span"), "secdeclare") + self.assertEqual(b.text("#app .listing-ct tbody:nth-of-type(1) td:nth-of-type(1)"), "3-4.b1") + self.assertEqual(b.text("#app .listing-ct tbody:nth-of-type(1) td:nth-of-type(2)"), "") + self.assertEqual(b.text("#app .listing-ct tbody:nth-of-type(1) td:nth-of-type(3) span.security-label-text"), + "Security Update: ") + desc = b.text("#app .listing-ct tbody:nth-of-type(1) td:nth-of-type(3)") + self.assertIn("stop kittens from dying", desc) + self.assertIn("CVE-2016-0001", desc) + + # secparse should also be considered a security update as the changelog mentions CVEs + self.assertEqual(b.text("#app .listing-ct tbody:nth-of-type(2) th span"), "secparse") + self.assertEqual(b.text("#app .listing-ct tbody:nth-of-type(2) td:nth-of-type(1)"), "4-2") + self.assertEqual(b.text("#app .listing-ct tbody:nth-of-type(2) td:nth-of-type(2)"), "") + self.assertEqual(b.text("#app .listing-ct tbody:nth-of-type(2) td:nth-of-type(3) span.security-label-text"), + "Security Update: ") + desc = b.text("#app .listing-ct tbody:nth-of-type(2) td:nth-of-type(3)") + self.assertIn("Fix CVE-2017-0001 and CVE-2017-0002.", desc) + + # buggy: bug refs, no security + self.assertEqual(b.text("#app .listing-ct tbody:nth-of-type(3) th span"), "buggy") + self.assertEqual(b.text("#app .listing-ct tbody:nth-of-type(3) td:nth-of-type(1)"), "2-2") + self.assertEqual(b.text("#app .listing-ct tbody:nth-of-type(3) td:nth-of-type(2)"), "123, 456") + self.assertIn("Fixit", b.text("#app .listing-ct tbody:nth-of-type(3) td:nth-of-type(3)")) + + # norefs: just changelog, show both binary packages + self.assertEqual(b.text("#app .listing-ct tbody:nth-of-type(4) th > div:nth-of-type(1) span"), "norefs-bin") + self.assertEqual(b.text("#app .listing-ct tbody:nth-of-type(4) th > div:nth-of-type(2) span"), "norefs-doc") + self.assertEqual(b.text("#app .listing-ct tbody:nth-of-type(4) td:nth-of-type(1)"), "2-1") + self.assertEqual(b.text("#app .listing-ct tbody:nth-of-type(4) td:nth-of-type(2)"), "") # no bugs + self.assertIn("Now 10% more unicorns", b.text("#app .listing-ct tbody:nth-of-type(4) td:nth-of-type(3)")) + + # install only security updates + self.assertEqual(b.text("#app .container-fluid button.btn-default"), "Install security updates") + b.click("#app .container-fluid button.btn-default") + + b.wait_in_text("#state", "Applying updates") + b.wait_present("#app div.progress-bar") + + # should have succeeded; 3 non-security updates left + b.wait_in_text("#state", "3 updates") + b.wait_in_text(".container-fluid h2", "Available Packages") + b.wait_in_text("table.listing-ct", "norefs-doc") + self.assertIn("buggy", b.text("table.listing-ct")) + self.assertNotIn("secdeclare", b.text("table.listing-ct")) + self.assertNotIn("secparse", b.text("table.listing-ct")) + + # new security versions are now installed + m.execute("test -f /stamp-secdeclare-3-4.b1 && test -f /stamp-secparse-4-2") + # but the three others are untouched + m.execute("test -f /stamp-buggy-2-1 && test -f /stamp-norefs-bin-1-1 && test -f /stamp-norefs-doc-1-1") + + # should now only have one button (no security updates left) + self.assertEqual(b.text("#app .container-fluid button"), "Install all updates") + b.click("#app .container-fluid button") + + b.wait_in_text("#state", "Applying updates") + b.wait_present("#app div.progress-bar") + + # should have succeeded + b.wait_in_text("#state", "No updates pending") + # empty state visible in main area + b.wait_present(".container-fluid div.blank-slate-pf") + + # new versions are now installed + m.execute("test -f /stamp-norefs-bin-2-1 && test -f /stamp-norefs-doc-2-1") + + def testUpdateError(self): + b = self.browser + m = self.machine + + self.createPackage("vapor", "1", "1", install=True) + self.createPackage("vapor", "1", "2") + + self.enableRepo() + m.execute("pkcon refresh") + + # break the upgrade by removing the generated packages from the repo + m.execute("rm -f /tmp/repo/vapor*.deb /tmp/repo/vapor*.rpm") + + m.start_cockpit() + b.login_and_go("/updates") + b.wait_present(".container-fluid h2") + b.wait_in_text(".container-fluid h2", "Available Packages") + self.assertEqual(b.text("#state"), "1 update") + + b.wait_present("#app .container-fluid button") + b.click("#app .container-fluid button") + + b.wait_in_text("#state", "Applying updates failed") + + # expecting one error message, so this should be unique + b.wait_present("#app .container-fluid pre") + self.assertRegexpMatches(b.text("#app .container-fluid pre"), "missing|downloading|not.*available|No such file or directory") + + # not expecting any buttons + self.assertFalse(b.is_present("#app button")) + + def testRunningUpdate(self): + # The main case for this is that cockpit-ws itself gets upgraded, which + # restarts the service and terminates the connection. As we can't + # (efficiently) build a newer working cockpit-ws package, test the two + # parts (reconnect and warning about disconnect) separately. + + # no security updates, no changelogs + b = self.browser + m = self.machine + + # updating this package takes longer than a cockpit start and building the page + self.createPackage("slow", "1", "1", install=True) + self.createPackage("slow", "1", "2", postinst='sleep 10') + self.enableRepo() + m.execute("pkcon refresh") + + m.start_cockpit() + b.login_and_go("/updates") + + b.wait_present("#app .container-fluid button") + b.click("#app .container-fluid button") + b.wait_in_text("#state", "Applying updates") + + # restarting should pick up that install progress + m.restart_cockpit() + b.login_and_go("/updates") + b.wait_present("#state") + b.wait_in_text("#state", "Applying updates") + b.wait_present("#app div.progress-bar") + + # should have succeeded + b.wait_in_text("#state", "No updates pending") + + # now pretend that there is a newer cockpit-ws available, warn about disconnect + self.createPackage("cockpit-ws", "999", "1") + self.createPackage("cockpit", "999", "1") # as that depends on same version of ws + self.enableRepo() + b.wait_in_text(".content-header-extra td button", "Check for updates") + b.click(".content-header-extra td button") + + b.wait_present(".container-fluid h2") + b.wait_in_text(".container-fluid h2", "Available Packages") + self.assertEqual(b.text("#state"), "2 updates") + + b.wait_present("table.listing-ct") + b.wait_in_text("table.listing-ct", "cockpit-ws") + + b.wait_present("#app div.alert-warning") + b.wait_in_text("#app div.alert-warning", "Cockpit itself will be updated") + + def testPackageKitCrash(self): + b = self.browser + m = self.machine + + # make sure we have enough time to crash PK + self.createPackage("slow", "1", "1", install=True) + self.createPackage("slow", "1", "2", postinst='sleep 10') + self.enableRepo() + m.execute("pkcon refresh") + + m.start_cockpit() + b.login_and_go("/updates") + + b.wait_present("#app .container-fluid button") + b.click("#app .container-fluid button") + + # let updates start and zap PackageKit + b.wait_present("#app div.progress-bar") + m.execute("systemctl kill --signal=SEGV packagekit.service") + + b.wait_in_text("#state", "Applying updates failed") + b.wait_present("#app .container-fluid pre") + self.assertEqual(b.text("#app .container-fluid pre"), "PackageKit crashed") + + def testNoPackageKit(self): + b = self.browser + m = self.machine + + m.execute('''systemctl stop packagekit.service + rm `systemctl show -p FragmentPath packagekit.service | cut -f2 -d=` + rm /usr/share/dbus-1/system-services/org.freedesktop.PackageKit.service + systemctl daemon-reload''') + + m.start_cockpit() + b.login_and_go("/updates") + + b.wait_present("#state") + b.wait_in_text("#state", "Loading available updates failed") + b.wait_present("#app pre") + b.wait_in_text("#app pre", "PackageKit is not installed") + + # + # Helper functions for creating packages/repository + # + + def createPackage(self, name, version, release, install=False, postinst=None, **updateinfo): + '''Create a dummy package in /tmp/repo on self.machine + + If install is True, install the package. Otherwise, update the package + index in /tmp/repo. + ''' + if self.isApt: + self.createDeb(name, version + '-' + release, postinst, install) + else: + self.createRpm(name, version, release, postinst, install) + if updateinfo: + self.updateInfo[(name, version, release)] = updateinfo + + def createDeb(self, name, version, postinst, install): + '''Create a dummy deb in /tmp/repo on self.machine + + If install is True, install the package. Otherwise, update the package + index in /tmp/repo. + ''' + deb = "/tmp/repo/{0}_{1}_all.deb".format(name, version) + if postinst: + postinstcode = "printf '#!/bin/sh\n{0}' > /tmp/b/DEBIAN/postinst; chmod 755 /tmp/b/DEBIAN/postinst".format(postinst) + else: + postinstcode = '' + cmd = '''mkdir -p /tmp/b/DEBIAN /tmp/repo + printf "Package: {0}\nVersion: {1}\nPriority: optional\nSection: test\nMaintainer: foo\nArchitecture: all\nDescription: dummy {0}\n" > /tmp/b/DEBIAN/control + {3} + touch /tmp/b/stamp-{0}-{1} + dpkg -b /tmp/b {2} + rm -r /tmp/b + '''.format(name, version, deb, postinstcode) + if install: + cmd += "dpkg -i " + deb + self.machine.execute(cmd) + + def createRpm(self, name, version, release, post, install): + '''Create a dummy rpm in /tmp/repo on self.machine + + If install is True, install the package. Otherwise, update the package + index in /tmp/repo. + ''' + if post: + postcode = '\n%%post\n' + post + else: + postcode = '' + cmd = '''printf 'Summary: dummy {0}\nName: {0}\nVersion: {1}\nRelease: {2}\nLicense: BSD\nBuildArch: noarch\n +%%install\ntouch $RPM_BUILD_ROOT/stamp-{0}-{1}-{2}\n +%%description\nTest package.\n +%%files\n/stamp-*\n +{3}' > /tmp/spec + rpmbuild -bb /tmp/spec + mkdir -p /tmp/repo + cp ~/rpmbuild/RPMS/noarch/*.rpm /tmp/repo + rm -rf ~/rpmbuild + '''.format(name, version, release, postcode) + if install: + cmd += "rpm -i /tmp/repo/{0}-{1}-{2}.*.rpm".format(name, version, release) + self.machine.execute(cmd) + + def createAptChangelogs(self): + # apt metadata has no formal field for bugs/CVEs, they are parsed from the changelog + for ((pkg, ver, rel), info) in self.updateInfo.items(): + changes = info.get("changes", "some changes") + if info.get("bugs"): + changes += " (Closes: {0})".format(", ".join(["#" + str(b) for b in info["bugs"]])) + if info.get("cves"): + changes += "\n * " + ", ".join(info["cves"]) + + path = "/tmp/repo/changelogs/{0}/{1}/{1}_{2}-{3}".format(pkg[0], pkg, ver, rel) + contents = '''{0} ({1}-{2}) unstable; urgency=medium + + * {3} + + -- Joe Developer Wed, 31 May 2017 14:52:25 +0200 +'''.format(pkg, ver, rel, changes) + self.machine.execute("mkdir -p $(dirname {0}); echo '{1}' > {0}".format(path, contents)) + + def createYumUpdateInfo(self): + xml = '\n\n' + for ((pkg, ver, rel), info) in self.updateInfo.items(): + refs = "" + for b in info.get("bugs", []): + refs += ' \n'.format(b) + for c in info.get("cves", []): + refs += ' \n'.format(c) + + xml += ''' + UPDATE-{pkg}-{ver}-{rel} + {pkg} {ver}-{rel} update + + {desc} + +{refs} + + + + + {pkg}-{ver}-{rel}.noarch.rpm + + + + +'''.format(pkg=pkg, ver=ver, rel=rel, refs=refs, + desc=info.get("changes", ""), severity=info.get("severity", "bugfix")) + + xml += '\n' + return xml + + def enableRepo(self): + if self.isApt: + self.createAptChangelogs() + # HACK: on Debian jessie, apt has an error propagation bug that causes "Err file: Packages" for each absent + # compression format with file:// sources, which breaks PackageKit; work around by providing all formats + self.machine.execute('''echo 'deb [trusted=yes] file:///tmp/repo /' > /etc/apt/sources.list.d/test.list + cd /tmp/repo; apt-ftparchive packages . > Packages + gzip -c Packages > Packages.gz; bzip2 -c Packages > Packages.bz2; xz -c Packages > Packages.xz + O=$(apt-ftparchive -o APT::FTPArchive::Release::Origin=cockpittest release .); echo "$O" > Release + echo 'Changelogs: http://localhost:12345/changelogs/@CHANGEPATH@' >> Release + setsid python -m SimpleHTTPServer 12345 >/dev/null 2>&1 < /dev/null & + ''') + self.machine.wait_for_cockpit_running(port=12345) # wait for changelog HTTP server to start up + else: + self.machine.execute('''printf '[updates]\nname=cockpittest\nbaseurl=file:///tmp/repo\nenabled=1\ngpgcheck=0\n' > /etc/yum.repos.d/cockpittest.repo + echo '{0}' > /tmp/updateinfo.xml + createrepo_c /tmp/repo + modifyrepo_c /tmp/updateinfo.xml /tmp/repo/repodata + $(which dnf 2>/dev/null|| which yum) clean all'''.format(self.createYumUpdateInfo())) + + + +if __name__ == '__main__': + test_main() diff --git a/tools/cockpit.spec b/tools/cockpit.spec index 2316e27ea06e..630418df4820 100644 --- a/tools/cockpit.spec +++ b/tools/cockpit.spec @@ -93,6 +93,7 @@ Recommends: %{name}-docker = %{version}-%{release} Suggests: %{name}-pcp = %{version}-%{release} Suggests: %{name}-kubernetes = %{version}-%{release} Suggests: %{name}-selinux = %{version}-%{release} +Suggests: %{name}-packagekit = %{version}-%{release} %endif @@ -201,6 +202,9 @@ find %{buildroot}%{_datadir}/%{name}/networkmanager -type f >> networkmanager.li echo '%dir %{_datadir}/%{name}/ostree' > ostree.list find %{buildroot}%{_datadir}/%{name}/ostree -type f >> ostree.list +echo '%dir %{_datadir}/%{name}/packagekit' >> packagekit.list +find %{buildroot}%{_datadir}/%{name}/packagekit -type f >> packagekit.list + echo '%dir %{_datadir}/%{name}/machines' > machines.list find %{buildroot}%{_datadir}/%{name}/machines -type f >> machines.list @@ -633,5 +637,15 @@ cluster. Installed on the Kubernetes master. This package is not yet complete. %{_libexecdir}/cockpit-stub %endif +%package packagekit +Summary: Cockpit user interface for package updates +Requires: %{name}-bridge >= %{required_base} +Requires: PackageKit + +%description packagekit +The Cockpit component for installing package updates, via PackageKit. + +%files packagekit -f packagekit.list + # The changelog is automatically generated and merged %changelog diff --git a/tools/debian/cockpit-packagekit.install b/tools/debian/cockpit-packagekit.install new file mode 100644 index 000000000000..ca059784c0da --- /dev/null +++ b/tools/debian/cockpit-packagekit.install @@ -0,0 +1 @@ +usr/share/cockpit/packagekit/ diff --git a/tools/debian/control b/tools/debian/control index 4c2f8bc09aa6..7dc1eeb198c9 100644 --- a/tools/debian/control +++ b/tools/debian/control @@ -48,6 +48,7 @@ Recommends: cockpit-storaged (= ${binary:Version}), Suggests: cockpit-doc (= ${binary:Version}), cockpit-pcp (>= ${source:Version}), cockpit-docker (= ${binary:Version}), + cockpit-packagekit (>= ${source:Version}), xdg-utils, Description: User interface for Linux servers Cockpit runs in a browser and can manage your network of GNU/Linux @@ -115,6 +116,14 @@ Depends: ${misc:Depends}, Description: Cockpit PCP integration Cockpit support for reading PCP metrics and loading PCP archives. +Package: cockpit-packagekit +Architecture: all +Depends: ${misc:Depends}, + cockpit-bridge (>= ${bridge:minversion}), + packagekit +Description: Cockpit user interface for package updates + The Cockpit component for installing package updates, via PackageKit. + Package: cockpit-storaged Architecture: all Depends: ${misc:Depends}, diff --git a/tools/debian/rules b/tools/debian/rules index 22d687452634..3e60fb290685 100755 --- a/tools/debian/rules +++ b/tools/debian/rules @@ -8,6 +8,12 @@ ifneq ($(shell dpkg -s libpcp3-dev >/dev/null 2>&1 && echo yes),yes) CONFIG_OPTIONS = --disable-pcp endif +# PackageKit crashes on update information on Ubuntu 16.04, which makes +# "Software Updates" useless (LP: #1689820) +ifneq ($(shell grep xenial /etc/os-release),) + export DH_OPTIONS = -Ncockpit-packagekit +endif + %: dh $@ --with=systemd,autoreconf diff --git a/webpack.config.js b/webpack.config.js index be2922ce89fc..5e7c93ae0830 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -121,6 +121,11 @@ var info = { "tuned/dialog.js", ], + "packagekit/updates": [ + "packagekit/updates.jsx", + "packagekit/updates.css", + ], + "users/users": [ "users/local.js", "users/users.css", @@ -183,6 +188,9 @@ var info = { "ostree/manifest.json", "ostree/index.html", + "packagekit/index.html", + "packagekit/manifest.json", + "playground/hammer.gif", "playground/manifest.json", "playground/jquery-patterns.html",